@controlplane/cli
Version:
Control Plane Corporation CLI
141 lines • 6.96 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.deriveAuthBaseUrl = deriveAuthBaseUrl;
exports.doHeadlessLogin = doHeadlessLogin;
const axios_1 = require("axios");
const jwt_decode_1 = require("jwt-decode");
const logger_1 = require("../util/logger");
const time_1 = require("../util/time");
// ANCHOR - Constants
// Default polling interval in seconds, used when the server does not suggest one
const DEFAULT_POLL_INTERVAL_SECONDS = 3;
// Number of consecutive poll failures tolerated before giving up
const MAX_CONSECUTIVE_POLL_FAILURES = 3;
// SECTION - Functions
/**
* Resolves the auth-service base URL for the headless login flow.
*
* @param {Discovery} discovery - The discovery document fetched from the API endpoint.
* @param {string} endpoint - The API endpoint configured in the profile (e.g., https://api.cpln.io).
* @returns {string} The auth-service base URL without a trailing slash.
*/
function deriveAuthBaseUrl(discovery, endpoint) {
let res = discovery.endpoints.auth;
if (!res) {
return endpoint.replace('api.', 'auth.');
}
return res;
}
/**
* Creates a new CLI login session on the auth service.
*
* @param {string} authBaseUrl - The auth-service base URL.
* @returns {Promise<CreateCliSessionResponse>} A promise that resolves to the created session details.
*/
async function createCliSession(authBaseUrl) {
// Ask the auth service for a new login session
const { data: session } = await axios_1.default.post(`${authBaseUrl}/api/cli/session`);
return session;
}
/**
* Polls the auth service until the login session completes, then returns the custom token.
*
* @param {string} authBaseUrl - The auth-service base URL.
* @param {CreateCliSessionResponse} session - The CLI login session being polled.
* @returns {Promise<string>} A promise that resolves to the Firebase custom token.
*/
async function pollForCustomToken(authBaseUrl, session) {
var _a;
const deadline = new Date(session.expires * 1000);
// Convert the server-suggested interval (seconds) into milliseconds
const intervalMillis = (session.interval || DEFAULT_POLL_INTERVAL_SECONDS) * 1000;
// Track consecutive transient failures so a single network blip does not kill the flow
let consecutiveFailures = 0;
while (new Date() < deadline) {
// Wait before each poll to respect the server-suggested interval
await (0, time_1.sleep)(intervalMillis);
let poll;
try {
// Poll the session using the CLI-only secret session id
const response = await axios_1.default.get(`${authBaseUrl}/api/cli/session/${session.sessionId}`);
poll = response.data;
// A successful poll resets the transient failure counter
consecutiveFailures = 0;
}
catch (e) {
// The server signals an expired or unknown session with HTTP 410
if (axios_1.default.isAxiosError(e) && ((_a = e.response) === null || _a === void 0 ? void 0 : _a.status) === 410) {
throw new Error('Login session expired. Run cpln login again.');
}
// Tolerate transient errors (network blips) up to a limit
consecutiveFailures += 1;
logger_1.logger.debug(`Login poll failed (${consecutiveFailures}/${MAX_CONSECUTIVE_POLL_FAILURES})`, e);
if (consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
const reason = e instanceof Error ? e.message : String(e);
throw new Error(`Failed to poll the login session after ${MAX_CONSECUTIVE_POLL_FAILURES} attempts: ${reason}`);
}
continue;
}
// The session completed: the server returns the custom token exactly once
if (poll.status === 'complete' && poll.customToken) {
return poll.customToken;
}
}
throw new Error('Timed out waiting for login.');
}
/**
* Exchanges a Firebase custom token for an ID token and refresh token.
*
* @param {Discovery} discovery - The discovery document containing the Firebase API key.
* @param {string} customToken - The Firebase custom token minted by the auth service.
* @returns {Promise<SignInWithCustomTokenResponse>} A promise that resolves to the Firebase tokens.
*/
async function exchangeCustomToken(discovery, customToken) {
// Exchange the custom token for ID and refresh tokens via the Firebase identity toolkit
const { data: tokens } = await axios_1.default.post(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${discovery.firebase.apiKey}`, {
token: customToken,
returnSecureToken: true,
});
return tokens;
}
/**
* Performs the headless login flow: creates a login session, prints the login URL and
* confirmation code, polls until the user completes sign-in in a browser, exchanges the
* resulting custom token for Firebase tokens, and stores them on the profile.
*
* The profile's auth info is only written on success: a failed or abandoned login
* (timeout, network outage, expired session) leaves any existing credentials intact.
*
* @param {Profile} profile - The profile to log in; its authInfo is updated in place.
* @returns {Promise<void>} A promise that resolves when the profile holds fresh tokens.
*/
async function doHeadlessLogin(profile) {
var _a;
// Fetch the discovery document from the configured API endpoint
const { data: discovery } = await axios_1.default.get(`${profile.request.endpoint}/discovery`);
// Resolve the auth-service base URL from discovery or the configured endpoint
const authBaseUrl = deriveAuthBaseUrl(discovery, profile.request.endpoint);
// Create a new CLI login session on the auth service
const session = await createCliSession(authBaseUrl);
// Show the user how to complete sign-in from any browser
console.log(`Open this URL in your browser to log in:\n\n ${session.loginUrl}\n`);
console.log(`Your confirmation code: ${session.userCode}\n`);
console.log('Waiting for login to complete... (code expires in ~10 minutes)');
// Poll until the user completes sign-in and the server releases the custom token
const customToken = await pollForCustomToken(authBaseUrl, session);
// Exchange the custom token for Firebase ID and refresh tokens
const tokens = await exchangeCustomToken(discovery, customToken);
// Decode the email claim from the ID token
const claims = (0, jwt_decode_1.jwtDecode)(tokens.idToken);
if (!claims.email) {
logger_1.logger.debug('The ID token has no email claim; storing an empty email');
}
// Store the fresh tokens and email on the profile
profile.authInfo = {
accessToken: tokens.idToken,
refreshToken: tokens.refreshToken,
email: (_a = claims.email) !== null && _a !== void 0 ? _a : '',
};
}
// !SECTION
//# sourceMappingURL=headlessLogin.js.map