From eefef3a7cfeac28a581b39fea119b60d2927d644 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 2 Apr 2026 18:00:17 -0400 Subject: [PATCH] Make store auth scopes additive --- .../services/store/admin-graphql-context.ts | 106 +----- .../cli/src/cli/services/store/auth.test.ts | 310 +++++++++++++++++- packages/cli/src/cli/services/store/auth.ts | 118 ++++++- .../src/cli/services/store/stored-session.ts | 104 ++++++ 4 files changed, 523 insertions(+), 115 deletions(-) create mode 100644 packages/cli/src/cli/services/store/stored-session.ts diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.ts b/packages/cli/src/cli/services/store/admin-graphql-context.ts index f014e5b9e9e..a0c5f6a6fe6 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-context.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-context.ts @@ -1,17 +1,11 @@ import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' import {AbortError} from '@shopify/cli-kit/node/error' -import {fetch} from '@shopify/cli-kit/node/http' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {AdminSession} from '@shopify/cli-kit/node/session' -import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' -import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './auth-recovery.js' -import { - clearStoredStoreAppSession, - getStoredStoreAppSession, - isSessionExpired, - setStoredStoreAppSession, - StoredStoreAppSession, -} from './session.js' +import {reauthenticateStoreAuthError} from './auth-recovery.js' +import {clearStoredStoreAppSession} from './session.js' +import type {StoredStoreAppSession} from './session.js' +import {loadStoredStoreSession} from './stored-session.js' export interface AdminStoreGraphQLContext { adminSession: AdminSession @@ -19,98 +13,6 @@ export interface AdminStoreGraphQLContext { sessionUserId: string } -async function refreshStoreToken(session: StoredStoreAppSession): Promise { - if (!session.refreshToken) { - throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(',')) - } - - const endpoint = `https://${session.store}/admin/oauth/access_token` - - outputDebug( - outputContent`Refreshing expired token for ${outputToken.raw(session.store)} (expired at ${outputToken.raw(session.expiresAt ?? 'unknown')}, refresh_token=${outputToken.raw(maskToken(session.refreshToken))})`, - ) - - const response = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - client_id: STORE_AUTH_APP_CLIENT_ID, - grant_type: 'refresh_token', - refresh_token: session.refreshToken, - }), - }) - - const body = await response.text() - - if (!response.ok) { - outputDebug( - outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, - ) - clearStoredStoreAppSession(session.store, session.userId) - throw reauthenticateStoreAuthError( - `Token refresh failed for ${session.store} (HTTP ${response.status}).`, - session.store, - session.scopes.join(','), - ) - } - - let data: {access_token?: string; refresh_token?: string; expires_in?: number; refresh_token_expires_in?: number} - try { - data = JSON.parse(body) - } catch { - clearStoredStoreAppSession(session.store, session.userId) - throw new AbortError('Received an invalid refresh response from Shopify.') - } - - if (!data.access_token) { - clearStoredStoreAppSession(session.store, session.userId) - throw reauthenticateStoreAuthError( - `Token refresh returned an invalid response for ${session.store}.`, - session.store, - session.scopes.join(','), - ) - } - - const now = Date.now() - const expiresAt = data.expires_in ? new Date(now + data.expires_in * 1000).toISOString() : session.expiresAt - - const refreshedSession: StoredStoreAppSession = { - ...session, - accessToken: data.access_token, - refreshToken: data.refresh_token ?? session.refreshToken, - expiresAt, - refreshTokenExpiresAt: data.refresh_token_expires_in - ? new Date(now + data.refresh_token_expires_in * 1000).toISOString() - : session.refreshTokenExpiresAt, - acquiredAt: new Date(now).toISOString(), - } - - outputDebug( - outputContent`Token refresh succeeded for ${outputToken.raw(session.store)}: ${outputToken.raw(maskToken(session.accessToken))} → ${outputToken.raw(maskToken(refreshedSession.accessToken))}, new expiry ${outputToken.raw(expiresAt ?? 'unknown')}`, - ) - - setStoredStoreAppSession(refreshedSession) - return refreshedSession -} - -async function loadStoredStoreSession(store: string): Promise { - let session = getStoredStoreAppSession(store) - - if (!session) { - throw createStoredStoreAuthError(store) - } - - outputDebug( - outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`, - ) - - if (isSessionExpired(session)) { - session = await refreshStoreToken(session) - } - - return session -} - async function resolveApiVersion(options: { session: StoredStoreAppSession adminSession: AdminSession diff --git a/packages/cli/src/cli/services/store/auth.test.ts b/packages/cli/src/cli/services/store/auth.test.ts index 0ae4fe7c94e..e15f620950b 100644 --- a/packages/cli/src/cli/services/store/auth.test.ts +++ b/packages/cli/src/cli/services/store/auth.test.ts @@ -7,13 +7,16 @@ import { generateCodeVerifier, computeCodeChallenge, exchangeStoreAuthCodeForToken, + resolveExistingStoreAuthScopes, waitForStoreAuthCode, } from './auth.js' -import {setStoredStoreAppSession} from './session.js' +import {loadStoredStoreSession} from './stored-session.js' +import {getStoredStoreAppSession, setStoredStoreAppSession} from './session.js' import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' import {fetch} from '@shopify/cli-kit/node/http' vi.mock('./session.js') +vi.mock('./stored-session.js', () => ({loadStoredStoreSession: vi.fn()})) vi.mock('@shopify/cli-kit/node/http') vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) @@ -115,6 +118,131 @@ describe('store auth service', () => { expect(url.searchParams.get('grant_options[]')).toBeNull() }) + test('resolveExistingStoreAuthScopes returns no scopes when no stored auth exists', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({scopes: [], authoritative: true}) + expect(loadStoredStoreSession).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + }) + + test('resolveExistingStoreAuthScopes prefers current remote scopes over stale local scopes', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(JSON.stringify({access_scopes: [{handle: 'read_products'}, {handle: 'read_customers'}]})), + } as any) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_products', 'read_customers'], + authoritative: true, + }) + expect(fetch).toHaveBeenCalledWith( + 'https://shop.myshopify.com/admin/oauth/access_scopes.json', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({'X-Shopify-Access-Token': 'fresh-token'}), + }), + ) + }) + + test('resolveExistingStoreAuthScopes falls back to locally stored scopes when remote lookup fails', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockRejectedValue(new Error('boom')) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + }) + + test('resolveExistingStoreAuthScopes falls back to locally stored scopes when access scopes request fails', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: vi.fn().mockResolvedValue('shopify exploded'), + } as any) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + }) + + test('resolveExistingStoreAuthScopes falls back to locally stored scopes when access scopes response is invalid', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('{"not_access_scopes":[]}'), + } as any) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + }) + test('waitForStoreAuthCode resolves after a valid callback', async () => { const port = await getAvailablePort() const params = callbackParams() @@ -354,6 +482,186 @@ describe('store auth service', () => { }) }) + test('authenticateStoreWithApp uses remote scopes by default when available', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(JSON.stringify({access_scopes: [{handle: 'read_customers'}]})), + } as any) + + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_customers,read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_customers,read_products') + }) + + test('authenticateStoreWithApp reuses resolved existing scopes when requesting additional access', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_orders,read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: true}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['read_orders', 'read_products'], + }), + ) + }) + + test('authenticateStoreWithApp does not require non-authoritative cached scopes to still be granted', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: false}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['read_products'], + }), + ) + }) + + test('authenticateStoreWithApp avoids requesting redundant read scopes already implied by existing write scopes', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'write_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['write_products'], authoritative: true}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('write_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['write_products'], + }), + ) + }) + test('authenticateStoreWithApp shows a manual auth URL when the browser does not open automatically', async () => { const openURL = vi.fn().mockResolvedValue(false) const presenter = { diff --git a/packages/cli/src/cli/services/store/auth.ts b/packages/cli/src/cli/services/store/auth.ts index 0b38fc842d3..8fd9e08d137 100644 --- a/packages/cli/src/cli/services/store/auth.ts +++ b/packages/cli/src/cli/services/store/auth.ts @@ -1,6 +1,8 @@ import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, STORE_AUTH_CALLBACK_PATH, maskToken, storeAuthRedirectUri} from './auth-config.js' import {retryStoreAuthWithPermanentDomainError} from './auth-recovery.js' -import {setStoredStoreAppSession} from './session.js' +import {getStoredStoreAppSession, setStoredStoreAppSession} from './session.js' +import type {StoredStoreAppSession} from './session.js' +import {loadStoredStoreSession} from './stored-session.js' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {randomUUID} from '@shopify/cli-kit/node/crypto' import {AbortError} from '@shopify/cli-kit/node/error' @@ -93,6 +95,87 @@ function expandImpliedStoreScopes(scopes: string[]): Set { return expandedScopes } +function mergeRequestedAndStoredScopes(requestedScopes: string[], storedScopes: string[]): string[] { + const mergedScopes = [...storedScopes] + const expandedScopes = expandImpliedStoreScopes(storedScopes) + + for (const scope of requestedScopes) { + if (expandedScopes.has(scope)) continue + + mergedScopes.push(scope) + for (const expandedScope of expandImpliedStoreScopes([scope])) { + expandedScopes.add(expandedScope) + } + } + + return mergedScopes +} + +interface StoreAccessScopesResponse { + access_scopes?: {handle?: string}[] +} + +interface ResolvedStoreAuthScopes { + scopes: string[] + authoritative: boolean +} + +async function fetchCurrentStoreAuthScopes(session: StoredStoreAppSession): Promise { + const endpoint = `https://${session.store}/admin/oauth/access_scopes.json` + + outputDebug( + outputContent`Fetching current app installation scopes for ${outputToken.raw(session.store)} using token ${outputToken.raw(maskToken(session.accessToken))}`, + ) + + const response = await fetch(endpoint, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Shopify-Access-Token': session.accessToken, + }, + }) + + const body = await response.text() + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${body || response.statusText}`) + } + + let parsed: StoreAccessScopesResponse + try { + parsed = JSON.parse(body) as StoreAccessScopesResponse + } catch { + throw new Error('Received an invalid access scopes response from Shopify.') + } + + if (!Array.isArray(parsed.access_scopes)) { + throw new Error('Shopify did not return access_scopes.') + } + + return parsed.access_scopes.flatMap((scope) => (typeof scope.handle === 'string' ? [scope.handle] : [])) +} + +export async function resolveExistingStoreAuthScopes(store: string): Promise { + const normalizedStore = normalizeStoreFqdn(store) + const storedSession = getStoredStoreAppSession(normalizedStore) + if (!storedSession) return {scopes: [], authoritative: true} + + try { + const usableSession = await loadStoredStoreSession(normalizedStore) + const remoteScopes = await fetchCurrentStoreAuthScopes(usableSession) + + outputDebug( + outputContent`Resolved current remote scopes for ${outputToken.raw(normalizedStore)}: ${outputToken.raw(remoteScopes.join(',') || 'none')}`, + ) + + return {scopes: remoteScopes, authoritative: true} + } catch (error) { + outputDebug( + outputContent`Falling back to locally stored scopes for ${outputToken.raw(normalizedStore)} after remote scope lookup failed: ${outputToken.raw(error instanceof Error ? error.message : String(error))}`, + ) + return {scopes: storedSession.scopes, authoritative: false} + } +} + function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requestedScopes: string[]): string[] { if (!tokenResponse.scope) { outputDebug(outputContent`Token response did not include scope; falling back to requested scopes`) @@ -363,6 +446,7 @@ interface StoreAuthDependencies { openURL: typeof openURL waitForStoreAuthCode: typeof waitForStoreAuthCode exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken + resolveExistingScopes?: (store: string) => Promise presenter: StoreAuthPresenter } @@ -394,12 +478,12 @@ const defaultStoreAuthDependencies: StoreAuthDependencies = { presenter: defaultStoreAuthPresenter, } -function createPkceBootstrap( - input: StoreAuthInput, - exchangeCodeForToken: typeof exchangeStoreAuthCodeForToken, -): StoreAuthBootstrap { - const store = normalizeStoreFqdn(input.store) - const scopes = parseStoreAuthScopes(input.scopes) +function createPkceBootstrap(options: { + store: string + scopes: string[] + exchangeCodeForToken: typeof exchangeStoreAuthCodeForToken +}): StoreAuthBootstrap { + const {store, scopes, exchangeCodeForToken} = options const port = DEFAULT_STORE_AUTH_PORT const state = randomUUID() const redirectUri = storeAuthRedirectUri(port) @@ -434,10 +518,20 @@ export async function authenticateStoreWithApp( input: StoreAuthInput, dependencies: StoreAuthDependencies = defaultStoreAuthDependencies, ): Promise { - const bootstrap = createPkceBootstrap(input, dependencies.exchangeStoreAuthCodeForToken) - const { - authorization: {store, scopes, redirectUri, authorizationUrl}, - } = bootstrap + const store = normalizeStoreFqdn(input.store) + const requestedScopes = parseStoreAuthScopes(input.scopes) + const existingScopeResolution = await (dependencies.resolveExistingScopes ?? resolveExistingStoreAuthScopes)(store) + const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) + const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes + + if (existingScopeResolution.scopes.length > 0) { + outputDebug( + outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`, + ) + } + + const bootstrap = createPkceBootstrap({store, scopes, exchangeCodeForToken: dependencies.exchangeStoreAuthCodeForToken}) + const {authorization: {authorizationUrl}} = bootstrap dependencies.presenter.openingBrowser() @@ -467,7 +561,7 @@ export async function authenticateStoreWithApp( // Store the raw scopes returned by Shopify. Validation may treat implied // write_* -> read_* permissions as satisfied, so callers should not assume // session.scopes is an expanded/effective permission set. - scopes: resolveGrantedScopes(tokenResponse, scopes), + scopes: resolveGrantedScopes(tokenResponse, validationScopes), acquiredAt: new Date(now).toISOString(), expiresAt, refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in diff --git a/packages/cli/src/cli/services/store/stored-session.ts b/packages/cli/src/cli/services/store/stored-session.ts new file mode 100644 index 00000000000..f41329b6b0b --- /dev/null +++ b/packages/cli/src/cli/services/store/stored-session.ts @@ -0,0 +1,104 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {fetch} from '@shopify/cli-kit/node/http' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' +import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './auth-recovery.js' +import { + clearStoredStoreAppSession, + getStoredStoreAppSession, + isSessionExpired, + setStoredStoreAppSession, +} from './session.js' +import type {StoredStoreAppSession} from './session.js' + +async function refreshStoreToken(session: StoredStoreAppSession): Promise { + if (!session.refreshToken) { + throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(',')) + } + + const endpoint = `https://${session.store}/admin/oauth/access_token` + + outputDebug( + outputContent`Refreshing expired token for ${outputToken.raw(session.store)} (expired at ${outputToken.raw(session.expiresAt ?? 'unknown')}, refresh_token=${outputToken.raw(maskToken(session.refreshToken))})`, + ) + + const response = await fetch(endpoint, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: session.refreshToken, + }), + }) + + const body = await response.text() + + if (!response.ok) { + outputDebug( + outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, + ) + clearStoredStoreAppSession(session.store, session.userId) + throw reauthenticateStoreAuthError( + `Token refresh failed for ${session.store} (HTTP ${response.status}).`, + session.store, + session.scopes.join(','), + ) + } + + let data: {access_token?: string; refresh_token?: string; expires_in?: number; refresh_token_expires_in?: number} + try { + data = JSON.parse(body) + } catch { + clearStoredStoreAppSession(session.store, session.userId) + throw new AbortError('Received an invalid refresh response from Shopify.') + } + + if (!data.access_token) { + clearStoredStoreAppSession(session.store, session.userId) + throw reauthenticateStoreAuthError( + `Token refresh returned an invalid response for ${session.store}.`, + session.store, + session.scopes.join(','), + ) + } + + const now = Date.now() + const expiresAt = data.expires_in ? new Date(now + data.expires_in * 1000).toISOString() : session.expiresAt + + const refreshedSession: StoredStoreAppSession = { + ...session, + accessToken: data.access_token, + refreshToken: data.refresh_token ?? session.refreshToken, + expiresAt, + refreshTokenExpiresAt: data.refresh_token_expires_in + ? new Date(now + data.refresh_token_expires_in * 1000).toISOString() + : session.refreshTokenExpiresAt, + acquiredAt: new Date(now).toISOString(), + } + + outputDebug( + outputContent`Token refresh succeeded for ${outputToken.raw(session.store)}: ${outputToken.raw(maskToken(session.accessToken))} → ${outputToken.raw(maskToken(refreshedSession.accessToken))}, new expiry ${outputToken.raw(expiresAt ?? 'unknown')}`, + ) + + setStoredStoreAppSession(refreshedSession) + return refreshedSession +} + +export async function loadStoredStoreSession(store: string): Promise { + let session = getStoredStoreAppSession(store) + + if (!session) { + throw createStoredStoreAuthError(store) + } + + outputDebug( + outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`, + ) + + if (isSessionExpired(session)) { + session = await refreshStoreToken(session) + } + + return session +}