UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

291 lines (290 loc) • 13 kB
import { applicationId } from './session/identity.js'; import { validateSession } from './session/validate.js'; import { allDefaultScopes, apiScopes } from './session/scopes.js'; import { exchangeAccessForApplicationTokens, exchangeCustomPartnerToken, refreshAccessToken, InvalidGrantError, InvalidRequestError, } from './session/exchange.js'; import * as secureStore from './session/store.js'; import { pollForDeviceAuthorization, requestDeviceAuthorization } from './session/device-authorization.js'; import { isThemeAccessSession } from './api/rest.js'; import { outputContent, outputToken, outputDebug, outputCompleted } from '../../public/node/output.js'; import { firstPartyDev, themeToken } from '../../public/node/context/local.js'; import { AbortError, BugError } from '../../public/node/error.js'; import { normalizeStoreFqdn, identityFqdn } from '../../public/node/context/fqdn.js'; import { getIdentityTokenInformation, getPartnersToken } from '../../public/node/environment.js'; import { isSpin } from '../../public/node/context/spin.js'; import { nonRandomUUID } from '../../public/node/crypto.js'; let userId; let authMethod = 'none'; /** * Retrieves the user ID from the current session or returns 'unknown' if not found. * * This function performs the following steps: * 1. Checks for a cached user ID in memory (obtained in the current run). * 2. Attempts to fetch it from the secure store (from a previous auth session). * 3. Checks if a custom token was used (either as a theme password or partners token). * 4. If a custom token is present in the environment, generates a UUID and uses it as userId. * 5. If after all this we don't have a userId, then reports as 'unknown'. * * @returns A Promise that resolves to the user ID as a string. */ export async function getLastSeenUserIdAfterAuth() { if (userId) return userId; const currentSession = (await secureStore.fetch()) || {}; const fqdn = await identityFqdn(); const cachedUserId = currentSession[fqdn]?.identity.userId; if (cachedUserId) return cachedUserId; const customToken = getPartnersToken() ?? themeToken(); return customToken ? nonRandomUUID(customToken) : 'unknown'; } export function setLastSeenUserIdAfterAuth(id) { userId = id; } /** * Retrieves the last seen authentication method used in the current session. * * This function checks for the authentication method in the following order: * 1. Returns the cached auth method if it's not 'none'. * 2. Checks for a cached session, which implies 'device_auth' was used. * 3. Checks for a partners token in the environment. * 4. Checks for a theme password in the environment. * 5. If none of the above are true, returns 'none'. * * @returns A Promise that resolves to the last seen authentication method as an AuthMethod type. */ export async function getLastSeenAuthMethod() { if (authMethod !== 'none') return authMethod; const currentSession = (await secureStore.fetch()) || {}; const fqdn = await identityFqdn(); const cachedUserId = currentSession[fqdn]?.identity.userId; if (cachedUserId) return 'device_auth'; const partnersToken = getPartnersToken(); if (partnersToken) return 'partners_token'; const themePassword = themeToken(); if (themePassword) { return isThemeAccessSession({ token: themePassword, storeFqdn: '' }) ? 'theme_access_token' : 'custom_app_token'; } return 'none'; } export function setLastSeenAuthMethod(method) { authMethod = method; } /** * This method ensures that we have a valid session to authenticate against the given applications using the provided scopes. * * @param applications - An object containing the applications we need to be authenticated with. * @param _env - Optional environment variables to use. * @param forceRefresh - Optional flag to force a refresh of the token. * @returns An instance with the access tokens organized by application. */ export async function ensureAuthenticated(applications, _env, { forceRefresh = false, noPrompt = false } = {}) { const fqdn = await identityFqdn(); const previousStoreFqdn = applications.adminApi?.storeFqdn; if (previousStoreFqdn) { const normalizedStoreName = await normalizeStoreFqdn(previousStoreFqdn); if (previousStoreFqdn === applications.adminApi?.storeFqdn) { applications.adminApi.storeFqdn = normalizedStoreName; } } const currentSession = (await secureStore.fetch()) || {}; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const fqdnSession = currentSession[fqdn]; const scopes = getFlattenScopes(applications); outputDebug(outputContent `Validating existing session against the scopes: ${outputToken.json(scopes)} For applications: ${outputToken.json(applications)} `); const validationResult = await validateSession(scopes, applications, fqdnSession); let newSession = {}; function throwOnNoPrompt() { if (!noPrompt || (isSpin() && firstPartyDev())) return; throw new AbortError(`The currently available CLI credentials are invalid. The CLI is currently unable to prompt for reauthentication.`, 'Restart the CLI process you were running. If in an interactive terminal, you will be prompted to reauthenticate. If in a non-interactive terminal, ensure the correct credentials are available in the program environment.'); } if (validationResult === 'needs_full_auth') { throwOnNoPrompt(); outputDebug(outputContent `Initiating the full authentication flow...`); newSession = await executeCompleteFlow(applications, fqdn); } else if (validationResult === 'needs_refresh' || forceRefresh) { outputDebug(outputContent `The current session is valid but needs refresh. Refreshing...`); try { newSession = await refreshTokens(fqdnSession.identity, applications, fqdn); } catch (error) { if (error instanceof InvalidGrantError) { throwOnNoPrompt(); newSession = await executeCompleteFlow(applications, fqdn); } else if (error instanceof InvalidRequestError) { await secureStore.remove(); throw new AbortError('\nError validating auth session', "We've cleared the current session, please try again"); } else { throw error; } } } const completeSession = { ...currentSession, ...newSession }; // Save the new session info if it has changed if (Object.keys(newSession).length > 0) await secureStore.store(completeSession); const tokens = await tokensFor(applications, completeSession, fqdn); // Overwrite partners token if using a custom CLI Token const envToken = getPartnersToken(); if (envToken && applications.partnersApi) { tokens.partners = (await exchangeCustomPartnerToken(envToken)).accessToken; } setLastSeenAuthMethod(envToken ? 'partners_token' : 'device_auth'); setLastSeenUserIdAfterAuth(tokens.userId); return tokens; } /** * Execute the full authentication flow. * * @param applications - An object containing the applications we need to be authenticated with. * @param identityFqdn - The identity FQDN. */ async function executeCompleteFlow(applications, identityFqdn) { const scopes = getFlattenScopes(applications); const exchangeScopes = getExchangeScopes(applications); const store = applications.adminApi?.storeFqdn; if (firstPartyDev()) { outputDebug(outputContent `Authenticating as Shopify Employee...`); scopes.push('employee'); } let identityToken; const identityTokenInformation = getIdentityTokenInformation(); if (identityTokenInformation) { identityToken = buildIdentityTokenFromEnv(scopes, identityTokenInformation); } else { // Request a device code to authorize without a browser redirect. outputDebug(outputContent `Requesting device authorization code...`); const deviceAuth = await requestDeviceAuthorization(scopes); // Poll for the identity token outputDebug(outputContent `Starting polling for the identity token...`); identityToken = await pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval); } // Exchange identity token for application tokens outputDebug(outputContent `CLI token received. Exchanging it for application tokens...`); const result = await exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store); const session = { [identityFqdn]: { identity: identityToken, applications: result, }, }; outputCompleted('Logged in.'); return session; } /** * Refresh the tokens for a given session. * * @param token - Identity token. * @param applications - An object containing the applications we need to be authenticated with. * @param fqdn - The identity FQDN. */ async function refreshTokens(token, applications, fqdn) { // Refresh Identity Token const identityToken = await refreshAccessToken(token); // Exchange new identity token for application tokens const exchangeScopes = getExchangeScopes(applications); const applicationTokens = await exchangeAccessForApplicationTokens(identityToken, exchangeScopes, applications.adminApi?.storeFqdn); return { [fqdn]: { identity: identityToken, applications: applicationTokens, }, }; } /** * Get the application tokens for a given session. * * @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, session, fqdn) { const fqdnSession = session[fqdn]; if (!fqdnSession) { throw new BugError('No session found after ensuring authenticated'); } const tokens = { userId: fqdnSession.identity.userId, }; if (applications.adminApi) { const appId = applicationId('admin'); const realAppId = `${applications.adminApi.storeFqdn}-${appId}`; const token = fqdnSession.applications[realAppId]?.accessToken; if (token) { tokens.admin = { token, storeFqdn: applications.adminApi.storeFqdn }; } } if (applications.partnersApi) { const appId = applicationId('partners'); tokens.partners = fqdnSession.applications[appId]?.accessToken; } if (applications.storefrontRendererApi) { const appId = applicationId('storefront-renderer'); tokens.storefront = fqdnSession.applications[appId]?.accessToken; } if (applications.businessPlatformApi) { const appId = applicationId('business-platform'); tokens.businessPlatform = fqdnSession.applications[appId]?.accessToken; } if (applications.appManagementApi) { const appId = applicationId('app-management'); tokens.appManagement = fqdnSession.applications[appId]?.accessToken; } return tokens; } // Scope Helpers /** * Get a flattened array of scopes for the given applications. * * @param apps - An object containing the applications we need the scopes for. * @returns A flattened array of scopes. */ function getFlattenScopes(apps) { const admin = apps.adminApi?.scopes || []; const partner = apps.partnersApi?.scopes || []; const storefront = apps.storefrontRendererApi?.scopes || []; const businessPlatform = apps.businessPlatformApi?.scopes || []; const appManagement = apps.appManagementApi?.scopes || []; const requestedScopes = [...admin, ...partner, ...storefront, ...businessPlatform, ...appManagement]; 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) { 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, identityTokenInformation) { return { ...identityTokenInformation, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), scopes, }; } //# sourceMappingURL=session.js.map