@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
JavaScript
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