UNPKG

@shopify/cli-kit

Version:

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

319 lines (318 loc) • 14.2 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 sessionStore from './session/store.js'; import { pollForDeviceAuthorization, requestDeviceAuthorization } from './session/device-authorization.js'; import { isThemeAccessSession } from './api/rest.js'; import { getCurrentSessionId, setCurrentSessionId } from './conf-store.js'; import { UserEmailQueryString } from './api/graphql/business-platform-destinations/user-email.js'; import { outputContent, outputToken, outputDebug, outputCompleted } from '../../public/node/output.js'; import { firstPartyDev, themeToken } from '../../public/node/context/local.js'; import { AbortError } from '../../public/node/error.js'; import { normalizeStoreFqdn, identityFqdn } from '../../public/node/context/fqdn.js'; import { getIdentityTokenInformation, getPartnersToken } from '../../public/node/environment.js'; import { logout } from '../../public/node/session.js'; import { nonRandomUUID } from '../../public/node/crypto.js'; import { isEmpty } from '../../public/common/object.js'; import { businessPlatformRequest } from '../../public/node/api/business-platform.js'; /** * Fetches the user's email from the Business Platform API * @param businessPlatformToken - The business platform token * @returns The user's email address or undefined if not found */ async function fetchEmail(businessPlatformToken) { if (!businessPlatformToken) return undefined; try { const userEmailResult = await businessPlatformRequest(UserEmailQueryString, businessPlatformToken); return userEmailResult.currentUserAccount?.email; // eslint-disable-next-line no-catch-all/no-catch-all } catch (error) { outputDebug(outputContent `Failed to fetch user email: ${error.message ?? String(error)}`); return undefined; } } 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 local storage (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 currentSessionId = getCurrentSessionId(); if (currentSessionId) return currentSessionId; 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; if (getCurrentSessionId()) 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 options - Optional extra options to use. * @returns An instance with the access tokens organized by application. */ export async function ensureAuthenticated(applications, _env, { forceRefresh = false, noPrompt = false, forceNewSession = false } = {}) { const fqdn = await identityFqdn(); const previousStoreFqdn = applications.adminApi?.storeFqdn; if (previousStoreFqdn) { const normalizedStoreName = normalizeStoreFqdn(previousStoreFqdn); if (previousStoreFqdn === applications.adminApi?.storeFqdn) { applications.adminApi.storeFqdn = normalizedStoreName; } } const sessions = (await sessionStore.fetch()) ?? {}; let currentSessionId = getCurrentSessionId(); if (!currentSessionId) { const userIds = Object.keys(sessions[fqdn] ?? {}); if (userIds.length > 0) currentSessionId = userIds[0]; } const currentSession = currentSessionId && !forceNewSession ? sessions[fqdn]?.[currentSessionId] : undefined; 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, currentSession); let newSession = {}; if (validationResult === 'needs_full_auth') { await throwOnNoPrompt(noPrompt); outputDebug(outputContent `Initiating the full authentication flow...`); newSession = await executeCompleteFlow(applications); } else if (validationResult === 'needs_refresh' || forceRefresh) { outputDebug(outputContent `The current session is valid but needs refresh. Refreshing...`); try { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion newSession = await refreshTokens(currentSession, applications); } catch (error) { if (error instanceof InvalidGrantError) { await throwOnNoPrompt(noPrompt); newSession = await executeCompleteFlow(applications); } else if (error instanceof InvalidRequestError) { await sessionStore.remove(); throw new AbortError('\nError validating auth session', "We've cleared the current session, please try again"); } else { throw error; } } } const completeSession = { ...currentSession, ...newSession }; const newSessionId = completeSession.identity.userId; const updatedSessions = { ...sessions, [fqdn]: { ...sessions[fqdn], [newSessionId]: completeSession }, }; // Save the new session info if it has changed if (!isEmpty(newSession)) { await sessionStore.store(updatedSessions); setCurrentSessionId(newSessionId); } const tokens = await tokensFor(applications, completeSession); // 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; } async function throwOnNoPrompt(noPrompt) { if (!noPrompt) return; await logout(); 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.'); } /** * Execute the full authentication flow. * * @param applications - An object containing the applications we need to be authenticated with. * @param alias - Optional alias to use for the session. */ async function executeCompleteFlow(applications) { 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); // Get the alias for the session (email or userId) const businessPlatformToken = result[applicationId('business-platform')]?.accessToken; const alias = (await fetchEmail(businessPlatformToken)) ?? identityToken.userId; const session = { identity: { ...identityToken, alias, }, applications: result, }; outputCompleted(`Logged in.`); return session; } /** * Refresh the tokens for a given session. * * @param session - The session to refresh. */ async function refreshTokens(session, applications) { // 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: 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) { const tokens = { userId: session.identity.userId, }; 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 /** * 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, alias: identityTokenInformation.userId, }; } //# sourceMappingURL=session.js.map