UNPKG

@sanity/cli

Version:

Sanity CLI tool for managing Sanity installations, managing plugins, schemas and datasets

351 lines (295 loc) • 10.4 kB
import http, {type Server} from 'node:http' import os from 'node:os' import {type SanityClient} from '@sanity/client' import open from 'open' import {debug as debugIt} from '../../debug' import { type CliApiClient, type CliCommandArguments, type CliCommandContext, type CliPrompter, } from '../../types' import {canLaunchBrowser} from '../../util/canLaunchBrowser' import {getCliToken} from '../../util/clientWrapper' import {TELEMETRY_CONSENT_CONFIG_KEY} from '../../util/createTelemetryStore' import {getUserConfig} from '../../util/getUserConfig' import {LoginTrace} from './login.telemetry' import {type LoginProvider, type ProvidersResponse, type SamlLoginProvider} from './types' const callbackEndpoint = '/callback' const debug = debugIt.extend('auth') const callbackPorts = [4321, 4000, 3003, 1234, 8080, 13333] const platformNames: Record<string, string | undefined> = { aix: 'AIX', android: 'Android', darwin: 'MacOS', freebsd: 'FreeBSD', linux: 'Linux', openbsd: 'OpenBSD', sunos: 'SunOS', win32: 'Windows', } export interface LoginFlags { experimental?: boolean open?: boolean provider?: string sso?: string } interface TokenDetails { token: string label: string } export async function login( args: CliCommandArguments<LoginFlags>, context: CliCommandContext, ): Promise<void> { const {prompt, output, apiClient, telemetry} = context const {sso, experimental, open: openFlag, provider: specifiedProvider} = args.extOptions const previousToken = getCliToken() const hasExistingToken = Boolean(previousToken) const trace = telemetry.trace(LoginTrace) trace.start() // Explicitly tell this client not to use a token const client = apiClient({requireUser: false, requireProject: false}) .clone() .config({token: undefined}) // Get the desired authentication provider const provider = await getProvider({client, sso, experimental, output, prompt, specifiedProvider}) trace.log({step: 'selectProvider', provider: provider?.name}) if (provider === undefined) { throw new Error('No authentication providers found') } // Initiate local listen server for OAuth callback const apiHost = client.config().apiHost || 'https://api.sanity.io' const {server, token: tokenPromise} = await startServerForTokenCallback({apiHost, apiClient}) trace.log({step: 'waitForToken'}) const serverUrl = server.address() if (!serverUrl || typeof serverUrl === 'string') { // Note: `serverUrl` is string only when binding to unix sockets, // thus we can safely assume Something Is Wrong™ if it's a string throw new Error('Failed to start auth callback server') } // Build a login URL that redirects back back to OAuth flow on success const loginUrl = new URL(provider.url) const platformName = os.platform() const platform = platformName in platformNames ? platformNames[platformName] : platformName const hostname = os.hostname().replace(/\.(local|lan)$/g, '') loginUrl.searchParams.set('type', 'token') loginUrl.searchParams.set('label', `${hostname} / ${platform}`) loginUrl.searchParams.set('origin', `http://localhost:${serverUrl.port}${callbackEndpoint}`) // Open a browser on the login page (or tell the user to) const shouldLaunchBrowser = canLaunchBrowser() && openFlag !== false const actionText = shouldLaunchBrowser ? 'Opening browser at' : 'Please open a browser at' output.print(`\n${actionText} ${loginUrl.href}\n`) const spin = output .spinner('Waiting for browser login to complete... Press Ctrl + C to cancel') .start() if (shouldLaunchBrowser) { open(loginUrl.href) } // Wait for a success/error on the HTTP callback server let authToken: string try { authToken = (await tokenPromise).token spin.stop() } catch (err) { spin.stop() trace.error(err) err.message = `Login failed: ${err.message}` throw err } finally { server.close() server.unref() } // Store the token getUserConfig().set({ authToken: authToken, authType: 'normal', }) // Clear cached telemetry consent getUserConfig().delete(TELEMETRY_CONSENT_CONFIG_KEY) // If we had a session previously, attempt to clear it if (hasExistingToken) { await apiClient({requireUser: true, requireProject: false}) .clone() .config({token: previousToken}) .request({uri: '/auth/logout', method: 'POST'}) .catch((err) => { const statusCode = err && err.response && err.response.statusCode if (statusCode !== 401) { output.warn('[warn] Failed to log out existing session') } }) } output.success('Login successful') trace.complete() } function startServerForTokenCallback(options: { apiHost: string apiClient: CliApiClient }): Promise<{token: Promise<TokenDetails>; server: Server}> { const {apiHost, apiClient} = options const domain = apiHost.includes('.sanity.work') ? 'www.sanity.work' : 'www.sanity.io' const attemptPorts = callbackPorts.slice() let callbackPort = attemptPorts.shift() let resolveToken: (resolvedToken: TokenDetails | PromiseLike<TokenDetails>) => void let rejectToken: (reason: Error) => void const tokenPromise = new Promise<TokenDetails>((resolve, reject) => { resolveToken = resolve rejectToken = reject }) return new Promise((resolve, reject) => { const server = http.createServer(async function onCallbackServerRequest(req, res) { function failLoginRequest(code = '') { res.writeHead(303, 'See Other', { Location: `https://${domain}/login/error${code ? `?error=${code}` : ''}`, }) res.end() server.close() } const url = new URL(req.url || '/', `http://localhost:${callbackPort}`) if (url.pathname !== callbackEndpoint) { res.writeHead(404, 'Not Found', {'Content-Type': 'text/plain'}) res.write('404 Not Found') res.end() return } const absoluteTokenUrl = url.searchParams.get('url') if (!absoluteTokenUrl) { failLoginRequest() return } const tokenUrl = new URL(absoluteTokenUrl) if (!tokenUrl.searchParams.has('sid')) { failLoginRequest('NO_SESSION_ID') return } let token: TokenDetails try { token = await apiClient({requireUser: false, requireProject: false}) .clone() .request({uri: `/auth/fetch${tokenUrl.search}`}) } catch (err) { failLoginRequest('UNRESOLVED_SESSION') rejectToken(err) return } res.writeHead(303, 'See Other', {Location: `https://${domain}/login/success`}) res.end() server.close() resolveToken(token) }) server.on('listening', function onCallbackListen() { // Once the server is successfully listening on a port, we can return the promise. // We'll then await the _token promise_, while the server is running in the background. resolve({token: tokenPromise, server}) }) server.on('error', function onCallbackServerError(err) { if ('code' in err && err.code === 'EADDRINUSE') { callbackPort = attemptPorts.shift() if (!callbackPort) { reject(new Error('Failed to find port number to bind auth callback server to')) return } debug('Port busy, trying %d', callbackPort) server.listen(callbackPort) } else { reject(err) } }) debug('Starting callback server on port %d', callbackPort) server.listen(callbackPort) }) } async function getProvider({ output, client, sso, experimental, prompt, specifiedProvider, }: { output: CliCommandContext['output'] client: SanityClient sso?: string experimental?: boolean prompt: CliPrompter specifiedProvider?: string }): Promise<LoginProvider | undefined> { if (sso) { return getSSOProvider({client, prompt, slug: sso}) } // Fetch and prompt for login provider to use const spin = output.spinner('Fetching providers...').start() let {providers} = await client.request<ProvidersResponse>({uri: '/auth/providers'}) if (experimental) { providers = [...providers, {name: 'sso', title: 'SSO', url: '_not_used_'}] } spin.stop() const providerNames = providers.map((prov) => prov.name) if (specifiedProvider && providerNames.includes(specifiedProvider)) { const provider = providers.find((prov) => prov.name === specifiedProvider) if (!provider) { throw new Error(`Cannot find login provider with name "${specifiedProvider}"`) } return provider } const provider = await promptProviders(prompt, providers) if (provider.name === 'sso') { const slug = await prompt.single({ type: 'input', message: 'Organization slug:', }) return getSSOProvider({client, prompt, slug}) } return provider } async function getSSOProvider({ client, prompt, slug, }: { client: SanityClient prompt: CliPrompter slug: string }): Promise<LoginProvider | undefined> { const providers = await client .withConfig({apiVersion: '2021-10-01'}) .request<SamlLoginProvider[]>({ uri: `/auth/organizations/by-slug/${slug}/providers`, }) const enabledProviders = providers.filter((candidate) => !candidate.disabled) if (enabledProviders.length === 0) { return undefined } if (enabledProviders.length === 1) { return samlProviderToLoginProvider(enabledProviders[0]) } const choice = await prompt.single({ type: 'list', message: 'Select SSO provider', choices: enabledProviders.map((provider) => provider.name), }) const foundProvider = enabledProviders.find((provider) => provider.name === choice) return foundProvider ? samlProviderToLoginProvider(foundProvider) : undefined } async function promptProviders( prompt: CliPrompter, providers: LoginProvider[], ): Promise<LoginProvider> { if (providers.length === 1) { return providers[0] } const provider = await prompt.single({ type: 'list', message: 'Please log in or create a new account', choices: providers.map((choice) => choice.title), }) return providers.find((prov) => prov.title === provider) || providers[0] } function samlProviderToLoginProvider(saml: SamlLoginProvider): LoginProvider { return { name: saml.name, title: saml.name, url: saml.loginUrl, } }