From 9d46d44fda1a5e6f7f66ccbf26c12ed56d2ef8df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 18 Mar 2026 13:29:41 +0100 Subject: [PATCH] Revert "Simplify authentication flow" --- .../cli-kit/src/private/node/session.test.ts | 144 +++++++++------- packages/cli-kit/src/private/node/session.ts | 93 +++++++++-- .../src/private/node/session/exchange.test.ts | 83 ++++++++++ .../src/private/node/session/exchange.ts | 38 +++++ .../src/private/node/session/validate.test.ts | 155 ++++++++++++++++-- .../src/private/node/session/validate.ts | 54 +++++- 6 files changed, 467 insertions(+), 100 deletions(-) diff --git a/packages/cli-kit/src/private/node/session.test.ts b/packages/cli-kit/src/private/node/session.test.ts index 6b9c9a07299..08eda6acff3 100644 --- a/packages/cli-kit/src/private/node/session.test.ts +++ b/packages/cli-kit/src/private/node/session.test.ts @@ -7,11 +7,17 @@ import { setLastSeenAuthMethod, setLastSeenUserIdAfterAuth, } from './session.js' -import {exchangeCustomPartnerToken, refreshAccessToken, InvalidGrantError} from './session/exchange.js' +import { + exchangeAccessForApplicationTokens, + exchangeCustomPartnerToken, + refreshAccessToken, + InvalidGrantError, +} from './session/exchange.js' import {allDefaultScopes} from './session/scopes.js' import {store as storeSessions, fetch as fetchSessions, remove as secureRemove} from './session/store.js' -import {IdentityToken, Sessions} from './session/schema.js' +import {ApplicationToken, IdentityToken, Sessions} from './session/schema.js' import {validateSession} from './session/validate.js' +import {applicationId} from './session/identity.js' import {pollForDeviceAuthorization, requestDeviceAuthorization} from './session/device-authorization.js' import {getCurrentSessionId} from './conf-store.js' import * as fqdnModule from '../../public/node/context/fqdn.js' @@ -44,15 +50,54 @@ const validIdentityToken: IdentityToken = { } const validTokens: OAuthSession = { - admin: {token: 'access_token', storeFqdn: 'mystore.myshopify.com'}, - storefront: 'access_token', - partners: 'access_token', + admin: {token: 'admin_token', storeFqdn: 'mystore.myshopify.com'}, + storefront: 'storefront_token', + partners: 'partners_token', userId, } +const appTokens: Record = { + // Admin APIs includes domain in the key + 'mystore.myshopify.com-admin': { + accessToken: 'admin_token', + expiresAt: futureDate, + scopes: ['scope', 'scope2'], + }, + 'storefront-renderer': { + accessToken: 'storefront_token', + expiresAt: futureDate, + scopes: ['scope1'], + }, + partners: { + accessToken: 'partners_token', + expiresAt: futureDate, + scopes: ['scope2'], + }, + 'business-platform': { + accessToken: 'business_platform_token', + expiresAt: futureDate, + scopes: ['scope3'], + }, +} + +const partnersToken: ApplicationToken = { + accessToken: 'custom_partners_token', + expiresAt: futureDate, + scopes: ['scope2'], +} + const fqdn = 'fqdn.com' const validSessions: Sessions = { + [fqdn]: { + [userId]: { + identity: validIdentityToken, + applications: appTokens, + }, + }, +} + +const invalidSessions: Sessions = { [fqdn]: { [userId]: { identity: validIdentityToken, @@ -62,6 +107,7 @@ const validSessions: Sessions = { } vi.mock('../../public/node/context/local.js') +vi.mock('./session/identity') vi.mock('./session/authorize') vi.mock('./session/exchange') vi.mock('./session/scopes') @@ -77,9 +123,11 @@ vi.mock('../../public/node/system.js') beforeEach(() => { vi.spyOn(fqdnModule, 'identityFqdn').mockResolvedValue(fqdn) + vi.mocked(exchangeAccessForApplicationTokens).mockResolvedValue(appTokens) vi.mocked(refreshAccessToken).mockResolvedValue(validIdentityToken) + vi.mocked(applicationId).mockImplementation((app) => app) vi.mocked(exchangeCustomPartnerToken).mockResolvedValue({ - accessToken: 'custom_partners_token', + accessToken: partnersToken.accessToken, userId: validIdentityToken.userId, }) vi.mocked(partnersRequest).mockResolvedValue(undefined) @@ -114,6 +162,7 @@ describe('ensureAuthenticated when previous session is invalid', () => { const got = await ensureAuthenticated(defaultApplications) // Then + expect(exchangeAccessForApplicationTokens).toBeCalled() expect(refreshAccessToken).not.toBeCalled() expect(businessPlatformRequest).toHaveBeenCalled() expect(storeSessions).toHaveBeenCalledOnce() @@ -153,16 +202,16 @@ The CLI is currently unable to prompt for reauthentication.`, test('executes complete auth flow if session is for a different fqdn', async () => { // Given vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth') - vi.mocked(fetchSessions).mockResolvedValue(validSessions) + vi.mocked(fetchSessions).mockResolvedValue(invalidSessions) const expectedSessions = { - ...validSessions, + ...invalidSessions, [fqdn]: { [userId]: { identity: { ...validIdentityToken, alias: 'user@example.com', }, - applications: {}, + applications: appTokens, }, }, } @@ -171,7 +220,7 @@ The CLI is currently unable to prompt for reauthentication.`, const got = await ensureAuthenticated(defaultApplications) // Then - + expect(exchangeAccessForApplicationTokens).toBeCalled() expect(refreshAccessToken).not.toBeCalled() expect(storeSessions).toBeCalledWith(expectedSessions) expect(got).toEqual(validTokens) @@ -190,7 +239,7 @@ The CLI is currently unable to prompt for reauthentication.`, const got = await ensureAuthenticated(defaultApplications) // Then - + expect(exchangeAccessForApplicationTokens).toBeCalled() expect(businessPlatformRequest).toHaveBeenCalled() expect(storeSessions).toHaveBeenCalledOnce() @@ -201,20 +250,26 @@ The CLI is currently unable to prompt for reauthentication.`, expect(got).toEqual(validTokens) }) - test('uses identity token to fetch email during full auth flow', async () => { + test('falls back to userId when no business platform token available', async () => { // Given vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth') vi.mocked(fetchSessions).mockResolvedValue(undefined) + const appTokensWithoutBusinessPlatform = { + 'mystore.myshopify.com-admin': appTokens['mystore.myshopify.com-admin']!, + 'storefront-renderer': appTokens['storefront-renderer']!, + partners: appTokens.partners!, + } + vi.mocked(exchangeAccessForApplicationTokens).mockResolvedValueOnce(appTokensWithoutBusinessPlatform) // When const got = await ensureAuthenticated(defaultApplications) // Then - expect(businessPlatformRequest).toHaveBeenCalledWith(expect.any(String), 'access_token') + expect(businessPlatformRequest).not.toHaveBeenCalled() - // Verify the session was stored with email as alias + // Verify the session was stored with userId as alias (fallback) const storedSession = vi.mocked(storeSessions).mock.calls[0]![0] - expect(storedSession[fqdn]![userId]!.identity.alias).toBe('user@example.com') + expect(storedSession[fqdn]![userId]!.identity.alias).toBe(userId) }) test('executes complete auth flow if requesting additional scopes', async () => { @@ -226,7 +281,7 @@ The CLI is currently unable to prompt for reauthentication.`, const got = await ensureAuthenticated(defaultApplications) // Then - + expect(exchangeAccessForApplicationTokens).toBeCalled() expect(refreshAccessToken).not.toBeCalled() expect(businessPlatformRequest).toHaveBeenCalled() expect(storeSessions).toHaveBeenCalledOnce() @@ -252,7 +307,7 @@ describe('when existing session is valid', () => { const got = await ensureAuthenticated(defaultApplications) // Then - + expect(exchangeAccessForApplicationTokens).not.toBeCalled() expect(refreshAccessToken).not.toBeCalled() expect(got).toEqual(validTokens) await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('1234-5678') @@ -265,18 +320,13 @@ describe('when existing session is valid', () => { vi.mocked(validateSession).mockResolvedValueOnce('ok') vi.mocked(fetchSessions).mockResolvedValue(validSessions) vi.mocked(getPartnersToken).mockReturnValue('custom_cli_token') - const expected = { - admin: {token: 'access_token', storeFqdn: 'mystore.myshopify.com'}, - storefront: 'access_token', - partners: 'custom_partners_token', - userId, - } + const expected = {...validTokens, partners: 'custom_partners_token'} // When const got = await ensureAuthenticated(defaultApplications) // Then - + expect(exchangeAccessForApplicationTokens).not.toBeCalled() expect(refreshAccessToken).not.toBeCalled() expect(got).toEqual(expected) await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('1234-5678') @@ -294,16 +344,8 @@ describe('when existing session is valid', () => { // Then expect(refreshAccessToken).toBeCalled() - - const expectedSessions = { - [fqdn]: { - [userId]: { - identity: validIdentityToken, - applications: {}, - }, - }, - } - expect(storeSessions).toBeCalledWith(expectedSessions) + expect(exchangeAccessForApplicationTokens).toBeCalled() + expect(storeSessions).toBeCalledWith(validSessions) expect(got).toEqual(validTokens) await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('1234-5678') await expect(getLastSeenAuthMethod()).resolves.toEqual('device_auth') @@ -322,16 +364,8 @@ describe('when existing session is expired', () => { // Then expect(refreshAccessToken).toBeCalled() - - const expectedSessions = { - [fqdn]: { - [userId]: { - identity: validIdentityToken, - applications: {}, - }, - }, - } - expect(storeSessions).toBeCalledWith(expectedSessions) + expect(exchangeAccessForApplicationTokens).toBeCalled() + expect(storeSessions).toBeCalledWith(validSessions) expect(got).toEqual(validTokens) await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('1234-5678') await expect(getLastSeenAuthMethod()).resolves.toEqual('device_auth') @@ -351,7 +385,7 @@ describe('when existing session is expired', () => { // Then expect(refreshAccessToken).toBeCalled() - + expect(exchangeAccessForApplicationTokens).toBeCalled() expect(businessPlatformRequest).toHaveBeenCalled() expect(storeSessions).toHaveBeenCalledOnce() @@ -610,15 +644,7 @@ describe('ensureAuthenticated email fetch functionality', () => { const got = await ensureAuthenticated(defaultApplications) // Then - const expectedSessions = { - [fqdn]: { - [userId]: { - identity: validIdentityToken, - applications: {}, - }, - }, - } - expect(storeSessions).toBeCalledWith(expectedSessions) + expect(storeSessions).toBeCalledWith(validSessions) expect(got).toEqual(validTokens) }) @@ -633,15 +659,7 @@ describe('ensureAuthenticated email fetch functionality', () => { // Then // The email fetch is not called during refresh - the session keeps its existing alias expect(businessPlatformRequest).not.toHaveBeenCalled() - const expectedSessions = { - [fqdn]: { - [userId]: { - identity: validIdentityToken, - applications: {}, - }, - }, - } - expect(storeSessions).toBeCalledWith(expectedSessions) + expect(storeSessions).toBeCalledWith(validSessions) expect(got).toEqual(validTokens) }) diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index ed3cdf9105d..46d48594c45 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -1,7 +1,10 @@ +import {applicationId} from './session/identity.js' import {validateSession} from './session/validate.js' -import {allDefaultScopes} from './session/scopes.js' +import {allDefaultScopes, apiScopes} from './session/scopes.js' import { + exchangeAccessForApplicationTokens, exchangeCustomPartnerToken, + ExchangeScopes, refreshAccessToken, InvalidGrantError, InvalidRequestError, @@ -220,7 +223,7 @@ For applications: ${outputToken.json(applications)} `) - const validationResult = await validateSession(scopes, currentSession) + const validationResult = await validateSession(scopes, applications, currentSession) let newSession = {} @@ -290,6 +293,8 @@ The CLI is currently unable to prompt for reauthentication.`, */ async function executeCompleteFlow(applications: OAuthApplications): Promise { const scopes = getFlattenScopes(applications) + const exchangeScopes = getExchangeScopes(applications) + const store = applications.adminApi?.storeFqdn if (firstPartyDev()) { outputDebug(outputContent`Authenticating as Shopify Employee...`) scopes.push('employee') @@ -309,18 +314,20 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise { - // Refresh Identity Token (PCAT) - no exchange needed +async function refreshTokens(session: Session, applications: OAuthApplications): Promise { + // Refresh Identity Token const identityToken = await refreshAccessToken(session.identity) + // Exchange new identity token for application tokens + const exchangeScopes = getExchangeScopes(applications) + const applicationTokens = await exchangeAccessForApplicationTokens( + identityToken, + exchangeScopes, + applications.adminApi?.storeFqdn, + ) return { identity: identityToken, - applications: {}, + applications: applicationTokens, } } /** * Get the application tokens for a given session. - * With PCAT, the identity token can be used directly for all APIs. * * @param applications - An object containing the applications we need the tokens for. * @param session - The current session. * @param fqdn - The identity FQDN. */ async function tokensFor(applications: OAuthApplications, session: Session): Promise { - const token = session.identity.accessToken - return { + const tokens: OAuthSession = { userId: session.identity.userId, - admin: applications.adminApi ? {token, storeFqdn: applications.adminApi.storeFqdn} : undefined, - partners: applications.partnersApi ? token : undefined, - storefront: applications.storefrontRendererApi ? token : undefined, - businessPlatform: applications.businessPlatformApi ? token : undefined, - appManagement: applications.appManagementApi ? token : undefined, } + + if (applications.adminApi) { + const appId = applicationId('admin') + const realAppId = `${applications.adminApi.storeFqdn}-${appId}` + const token = session.applications[realAppId]?.accessToken + if (token) { + tokens.admin = {token, storeFqdn: applications.adminApi.storeFqdn} + } + } + + if (applications.partnersApi) { + const appId = applicationId('partners') + tokens.partners = session.applications[appId]?.accessToken + } + + if (applications.storefrontRendererApi) { + const appId = applicationId('storefront-renderer') + tokens.storefront = session.applications[appId]?.accessToken + } + + if (applications.businessPlatformApi) { + const appId = applicationId('business-platform') + tokens.businessPlatform = session.applications[appId]?.accessToken + } + + if (applications.appManagementApi) { + const appId = applicationId('app-management') + tokens.appManagement = session.applications[appId]?.accessToken + } + + return tokens } // Scope Helpers @@ -380,6 +418,27 @@ function getFlattenScopes(apps: OAuthApplications): string[] { return allDefaultScopes(requestedScopes) } +/** + * Get the scopes for the given applications. + * + * @param apps - An object containing the applications we need the scopes for. + * @returns An object containing the scopes for each application. + */ +function getExchangeScopes(apps: OAuthApplications): ExchangeScopes { + const adminScope = apps.adminApi?.scopes ?? [] + const partnerScope = apps.partnersApi?.scopes ?? [] + const storefrontScopes = apps.storefrontRendererApi?.scopes ?? [] + const businessPlatformScopes = apps.businessPlatformApi?.scopes ?? [] + const appManagementScopes = apps.appManagementApi?.scopes ?? [] + return { + admin: apiScopes('admin', adminScope), + partners: apiScopes('partners', partnerScope), + storefront: apiScopes('storefront-renderer', storefrontScopes), + businessPlatform: apiScopes('business-platform', businessPlatformScopes), + appManagement: apiScopes('app-management', appManagementScopes), + } +} + function buildIdentityTokenFromEnv( scopes: string[], identityTokenInformation: {accessToken: string; refreshToken: string; userId: string}, 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 8204ee35046..22befb2f7bc 100644 --- a/packages/cli-kit/src/private/node/session/exchange.test.ts +++ b/packages/cli-kit/src/private/node/session/exchange.test.ts @@ -1,4 +1,5 @@ import { + exchangeAccessForApplicationTokens, exchangeCustomPartnerToken, exchangeCliTokenForAppManagementAccessToken, exchangeCliTokenForBusinessPlatformAccessToken, @@ -54,6 +55,88 @@ afterAll(() => { vi.useRealTimers() }) +describe('exchange identity token for application tokens', () => { + const scopes = {admin: [], partners: [], storefront: [], businessPlatform: [], appManagement: []} + + test('returns tokens for all APIs if a store is passed', async () => { + // Given + vi.mocked(shopifyFetch).mockImplementation(async () => Promise.resolve(new Response(JSON.stringify(data)))) + + // When + const got = await exchangeAccessForApplicationTokens(identityToken, scopes, 'storeFQDN') + + // Then + const expected = { + 'app-management': { + accessToken: 'access_token', + expiresAt: expiredDate, + scopes: ['scope', 'scope2'], + }, + partners: { + accessToken: 'access_token', + expiresAt: expiredDate, + scopes: ['scope', 'scope2'], + }, + 'storefront-renderer': { + accessToken: 'access_token', + expiresAt: expiredDate, + scopes: ['scope', 'scope2'], + }, + 'storeFQDN-admin': { + accessToken: 'access_token', + expiresAt: expiredDate, + scopes: ['scope', 'scope2'], + }, + 'business-platform': { + accessToken: 'access_token', + expiresAt: expiredDate, + scopes: ['scope', 'scope2'], + }, + } + expect(got).toEqual(expected) + }) + + test('does not return token for admin if there is no store', async () => { + // Given + const response = new Response(JSON.stringify(data)) + + // Need to do it 3 times because a Response can only be used once + vi.mocked(shopifyFetch) + .mockResolvedValue(response) + .mockResolvedValueOnce(response.clone()) + .mockResolvedValueOnce(response.clone()) + .mockResolvedValueOnce(response.clone()) + + // When + const got = await exchangeAccessForApplicationTokens(identityToken, scopes, undefined) + + // Then + const expected = { + 'app-management': { + accessToken: 'access_token', + expiresAt: expiredDate, + scopes: ['scope', 'scope2'], + }, + partners: { + accessToken: 'access_token', + expiresAt: expiredDate, + scopes: ['scope', 'scope2'], + }, + 'storefront-renderer': { + accessToken: 'access_token', + expiresAt: expiredDate, + scopes: ['scope', 'scope2'], + }, + 'business-platform': { + accessToken: 'access_token', + expiresAt: expiredDate, + scopes: ['scope', 'scope2'], + }, + } + expect(got).toEqual(expected) + }) +}) + describe('refresh access tokens', () => { test('throws an InvalidGrantError when Identity returns invalid_grant', async () => { // Given diff --git a/packages/cli-kit/src/private/node/session/exchange.ts b/packages/cli-kit/src/private/node/session/exchange.ts index 635629cb1cc..ebf49f3d6a9 100644 --- a/packages/cli-kit/src/private/node/session/exchange.ts +++ b/packages/cli-kit/src/private/node/session/exchange.ts @@ -15,6 +15,44 @@ export class InvalidGrantError extends ExtendableError {} export class InvalidRequestError extends ExtendableError {} class InvalidTargetError extends AbortError {} +export interface ExchangeScopes { + admin: string[] + partners: string[] + storefront: string[] + businessPlatform: string[] + appManagement: string[] +} + +/** + * Given an identity token, request an application token. + * @param identityToken - access token obtained in a previous step + * @param store - the store to use, only needed for admin API + * @returns An array with the application access tokens. + */ +export async function exchangeAccessForApplicationTokens( + identityToken: IdentityToken, + scopes: ExchangeScopes, + store?: string, +): Promise> { + const token = identityToken.accessToken + + const [partners, storefront, businessPlatform, admin, appManagement] = await Promise.all([ + requestAppToken('partners', token, scopes.partners), + requestAppToken('storefront-renderer', token, scopes.storefront), + requestAppToken('business-platform', token, scopes.businessPlatform), + store ? requestAppToken('admin', token, scopes.admin, store) : {}, + requestAppToken('app-management', token, scopes.appManagement), + ]) + + return { + ...partners, + ...storefront, + ...businessPlatform, + ...admin, + ...appManagement, + } +} + /** * Given an expired access token, refresh it to get a new one. */ diff --git a/packages/cli-kit/src/private/node/session/validate.test.ts b/packages/cli-kit/src/private/node/session/validate.test.ts index e42cb58a0b5..12f8300b683 100644 --- a/packages/cli-kit/src/private/node/session/validate.test.ts +++ b/packages/cli-kit/src/private/node/session/validate.test.ts @@ -1,10 +1,13 @@ import {validateSession} from './validate.js' +import {applicationId} from './identity.js' import {IdentityToken, validateCachedIdentityTokenStructure} from './schema.js' +import {OAuthApplications} from '../session.js' import {expect, describe, test, vi, afterAll, beforeEach} from 'vitest' const pastDate = new Date(2022, 1, 1, 9) const currentDate = new Date(2022, 1, 1, 10) const futureDate = new Date(2022, 1, 1, 11) +const storeName = 'store.myshopify.io' const requestedScopes = ['scope', 'scope2'] const validIdentity: IdentityToken = { @@ -25,67 +28,197 @@ const expiredIdentity: IdentityToken = { alias: '1234-5678', } +const validApplications = { + partners: { + accessToken: 'access_token', + expiresAt: futureDate, + scopes: ['scope'], + }, + 'storefront-renderer': { + accessToken: 'access_token', + expiresAt: futureDate, + scopes: ['scope'], + }, + [`${storeName}-admin`]: { + accessToken: 'access_token', + expiresAt: futureDate, + scopes: ['scope'], + }, +} + +const expiredApplications = { + partners: { + accessToken: 'access_token', + expiresAt: pastDate, + scopes: ['scope'], + }, + 'storefront-renderer': { + accessToken: 'access_token', + expiresAt: pastDate, + scopes: ['scope'], + }, + [`${storeName}-admin`]: { + accessToken: 'access_token', + expiresAt: pastDate, + scopes: ['scope'], + }, +} + +const defaultApps: OAuthApplications = { + partnersApi: {scopes: []}, + adminApi: {scopes: [], storeFqdn: storeName}, + storefrontRendererApi: {scopes: []}, +} + vi.mock('./identity-token-validation') +vi.mock('./identity') vi.mock('./schema') beforeEach(() => { + vi.mocked(applicationId).mockImplementation((id: any) => id) vi.mocked(validateCachedIdentityTokenStructure).mockReturnValue(true) vi.setSystemTime(currentDate) }) afterAll(() => { + // Restore Date mock vi.useRealTimers() }) describe('validateSession', () => { test('returns ok if session is valid', async () => { + // Given const session = { identity: validIdentity, - applications: {}, + applications: validApplications, } - const got = await validateSession(requestedScopes, session) + // When + const got = await validateSession(requestedScopes, defaultApps, session) + // Then expect(got).toBe('ok') }) test('returns needs_full_auth if validateCachedIdentityTokenStructure returns false', async () => { + // Given const session = { identity: validIdentity, - applications: {}, + applications: validApplications, } vi.mocked(validateCachedIdentityTokenStructure).mockReturnValueOnce(false) - const got = await validateSession(requestedScopes, session) + // When + const got = await validateSession(requestedScopes, defaultApps, session) + // Then expect(got).toBe('needs_full_auth') }) test('returns needs_full_auth if there is no session', async () => { - const got = await validateSession(requestedScopes, undefined) + // Given + const session: any = undefined + // When + const got = await validateSession(requestedScopes, defaultApps, session) + + // Then expect(got).toBe('needs_full_auth') }) - test('returns needs_full_auth if requested scopes are not included in token', async () => { + test('returns needs_full_auth if there is requested scopes are not included in token', async () => { + // Given const session = { identity: validIdentity, - applications: {}, + applications: validApplications, } - const got = await validateSession(['random_scope'], session) + // When + const got = await validateSession(['random_scope'], defaultApps, session) + // Then expect(got).toBe('needs_full_auth') }) - test('returns needs_refresh if identity token is expired', async () => { + test('returns needs_refresh if identity is expired', async () => { + // Given const session = { identity: expiredIdentity, - applications: {}, + applications: validApplications, + } + + // When + const got = await validateSession(requestedScopes, defaultApps, session) + + // Then + expect(got).toBe('needs_refresh') + }) + + test('returns needs_refresh if requesting partners and is expired', async () => { + // Given + const applications = { + partnersApi: {scopes: []}, + } + const session = { + identity: validIdentity, + applications: expiredApplications, + } + + // When + const got = await validateSession(requestedScopes, applications, session) + + // Then + expect(got).toBe('needs_refresh') + }) + + test('returns needs_refresh if requesting storefront and is expired', async () => { + // Given + const applications = { + storefrontRendererApi: {scopes: []}, + } + const session = { + identity: validIdentity, + applications: expiredApplications, + } + + // When + const got = await validateSession(requestedScopes, applications, session) + + // Then + expect(got).toBe('needs_refresh') + }) + + test('returns needs_refresh if requesting admin and is expired', async () => { + // Given + const applications: OAuthApplications = { + adminApi: {scopes: [], storeFqdn: storeName}, + } + const session = { + identity: validIdentity, + applications: expiredApplications, + } + + // When + const got = await validateSession(requestedScopes, applications, session) + + // Then + expect(got).toBe('needs_refresh') + }) + + test('returns needs_refresh if session does not include requested store', async () => { + // Given + const applications: OAuthApplications = { + adminApi: {scopes: [], storeFqdn: 'NotMyStore'}, + } + const session = { + identity: validIdentity, + applications: validApplications, } - const got = await validateSession(requestedScopes, session) + // When + const got = await validateSession(requestedScopes, applications, session) + // Then expect(got).toBe('needs_refresh') }) }) diff --git a/packages/cli-kit/src/private/node/session/validate.ts b/packages/cli-kit/src/private/node/session/validate.ts index 4cedd12f87a..d4b79799284 100644 --- a/packages/cli-kit/src/private/node/session/validate.ts +++ b/packages/cli-kit/src/private/node/session/validate.ts @@ -1,7 +1,9 @@ -import {IdentityToken, Session, validateCachedIdentityTokenStructure} from './schema.js' +import {ApplicationToken, IdentityToken, Session, validateCachedIdentityTokenStructure} from './schema.js' +import {applicationId} from './identity.js' import {sessionConstants} from '../constants.js' import {firstPartyDev} from '../../../public/node/context/local.js' +import {OAuthApplications} from '../session.js' import {outputDebug} from '../../../public/node/output.js' type ValidationResult = 'needs_refresh' | 'needs_full_auth' | 'ok' @@ -16,29 +18,63 @@ function validateScopes(requestedScopes: string[], identity: IdentityToken) { } /** - * Validate if the current session is valid or we need to refresh/re-authenticate. - * With PCAT, only the identity token needs validation - no per-application tokens. + * Validate if the current session is valid or we need to refresh/re-authenticate * @param scopes - requested scopes to validate - * @param session - current session with identity token + * @param applications - requested applications + * @param session - current session with identity and application tokens * @returns 'ok' if the session is valid, 'needs_full_auth' if we need to re-authenticate, 'needs_refresh' if we need to refresh the session */ -export async function validateSession(scopes: string[], session: Session | undefined): Promise { +export async function validateSession( + scopes: string[], + applications: OAuthApplications, + session: Session | undefined, +): Promise { if (!session) return 'needs_full_auth' const scopesAreValid = validateScopes(scopes, session.identity) if (!scopesAreValid) return 'needs_full_auth' + let tokensAreExpired = isTokenExpired(session.identity) + + if (applications.partnersApi) { + const appId = applicationId('partners') + const token = session.applications[appId]! + tokensAreExpired = tokensAreExpired || isTokenExpired(token) + } + + if (applications.appManagementApi) { + const appId = applicationId('app-management') + const token = session.applications[appId]! + tokensAreExpired = tokensAreExpired || isTokenExpired(token) + } + + if (applications.storefrontRendererApi) { + const appId = applicationId('storefront-renderer') + const token = session.applications[appId]! + tokensAreExpired = tokensAreExpired || isTokenExpired(token) + } + + if (applications.adminApi) { + const appId = applicationId('admin') + const realAppId = `${applications.adminApi.storeFqdn}-${appId}` + const token = session.applications[realAppId]! + tokensAreExpired = tokensAreExpired || isTokenExpired(token) + } + + outputDebug(`- Token validation -> It's expired: ${tokensAreExpired}`) if (!validateCachedIdentityTokenStructure(session.identity)) { return 'needs_full_auth' } - const expired = session.identity.expiresAt < expireThreshold() - outputDebug(`- Token validation -> It's expired: ${expired}`) - - if (expired) return 'needs_refresh' + if (tokensAreExpired) return 'needs_refresh' return 'ok' } +function isTokenExpired(token: ApplicationToken): boolean { + if (!token) return true + return token.expiresAt < expireThreshold() +} + function expireThreshold(): Date { return new Date(Date.now() + sessionConstants.expirationTimeMarginInMinutes * 60 * 1000) }