Ei kuvausta
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

auth.js 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. "use strict";
  2. Object.defineProperty(exports, "__esModule", { value: true });
  3. exports.logout = exports.getAccessToken = exports.findAccountByEmail = exports.loginGithub = exports.loginGoogle = exports.setGlobalDefaultAccount = exports.setProjectAccount = exports.loginAdditionalAccount = exports.selectAccount = exports.setRefreshToken = exports.setActiveAccount = exports.getAllAccounts = exports.getAdditionalAccounts = exports.getProjectDefaultAccount = exports.getGlobalDefaultAccount = void 0;
  4. const clc = require("colorette");
  5. const FormData = require("form-data");
  6. const fs = require("fs");
  7. const http = require("http");
  8. const jwt = require("jsonwebtoken");
  9. const opn = require("open");
  10. const path = require("path");
  11. const portfinder = require("portfinder");
  12. const url = require("url");
  13. const util = require("util");
  14. const apiv2 = require("./apiv2");
  15. const configstore_1 = require("./configstore");
  16. const error_1 = require("./error");
  17. const utils = require("./utils");
  18. const logger_1 = require("./logger");
  19. const prompt_1 = require("./prompt");
  20. const scopes = require("./scopes");
  21. const defaultCredentials_1 = require("./defaultCredentials");
  22. const uuid_1 = require("uuid");
  23. const crypto_1 = require("crypto");
  24. const track_1 = require("./track");
  25. const api_1 = require("./api");
  26. portfinder.setBasePort(9005);
  27. function getGlobalDefaultAccount() {
  28. const user = configstore_1.configstore.get("user");
  29. const tokens = configstore_1.configstore.get("tokens");
  30. if (!user || !tokens) {
  31. return undefined;
  32. }
  33. return {
  34. user,
  35. tokens,
  36. };
  37. }
  38. exports.getGlobalDefaultAccount = getGlobalDefaultAccount;
  39. function getProjectDefaultAccount(projectDir) {
  40. if (!projectDir) {
  41. return getGlobalDefaultAccount();
  42. }
  43. const activeAccounts = configstore_1.configstore.get("activeAccounts") || {};
  44. const email = activeAccounts[projectDir];
  45. if (!email) {
  46. return getGlobalDefaultAccount();
  47. }
  48. const allAccounts = getAllAccounts();
  49. return allAccounts.find((a) => a.user.email === email);
  50. }
  51. exports.getProjectDefaultAccount = getProjectDefaultAccount;
  52. function getAdditionalAccounts() {
  53. return configstore_1.configstore.get("additionalAccounts") || [];
  54. }
  55. exports.getAdditionalAccounts = getAdditionalAccounts;
  56. function getAllAccounts() {
  57. const res = [];
  58. const defaultUser = getGlobalDefaultAccount();
  59. if (defaultUser) {
  60. res.push(defaultUser);
  61. }
  62. res.push(...getAdditionalAccounts());
  63. return res;
  64. }
  65. exports.getAllAccounts = getAllAccounts;
  66. function setActiveAccount(options, account) {
  67. if (account.tokens.refresh_token) {
  68. setRefreshToken(account.tokens.refresh_token);
  69. }
  70. options.user = account.user;
  71. options.tokens = account.tokens;
  72. }
  73. exports.setActiveAccount = setActiveAccount;
  74. function setRefreshToken(token) {
  75. apiv2.setRefreshToken(token);
  76. }
  77. exports.setRefreshToken = setRefreshToken;
  78. function selectAccount(account, projectRoot) {
  79. const defaultUser = getProjectDefaultAccount(projectRoot);
  80. if (!account) {
  81. return defaultUser;
  82. }
  83. if (!defaultUser) {
  84. throw new error_1.FirebaseError(`Account ${account} not found, have you run "firebase login"?`);
  85. }
  86. const matchingAccount = getAllAccounts().find((a) => a.user.email === account);
  87. if (matchingAccount) {
  88. return matchingAccount;
  89. }
  90. throw new error_1.FirebaseError(`Account ${account} not found, run "firebase login:list" to see existing accounts or "firebase login:add" to add a new one`);
  91. }
  92. exports.selectAccount = selectAccount;
  93. async function loginAdditionalAccount(useLocalhost, email) {
  94. const result = await loginGoogle(useLocalhost, email);
  95. if (typeof result.user === "string") {
  96. throw new error_1.FirebaseError("Failed to parse auth response, see debug log.");
  97. }
  98. const resultEmail = result.user.email;
  99. if (email && resultEmail !== email) {
  100. utils.logWarning(`Chosen account ${resultEmail} does not match account hint ${email}`);
  101. }
  102. const allAccounts = getAllAccounts();
  103. const newAccount = {
  104. user: result.user,
  105. tokens: result.tokens,
  106. };
  107. const existingAccount = allAccounts.find((a) => a.user.email === resultEmail);
  108. if (existingAccount) {
  109. utils.logWarning(`Already logged in as ${resultEmail}.`);
  110. updateAccount(newAccount);
  111. }
  112. else {
  113. const additionalAccounts = getAdditionalAccounts();
  114. additionalAccounts.push(newAccount);
  115. configstore_1.configstore.set("additionalAccounts", additionalAccounts);
  116. }
  117. return newAccount;
  118. }
  119. exports.loginAdditionalAccount = loginAdditionalAccount;
  120. function setProjectAccount(projectDir, email) {
  121. logger_1.logger.debug(`setProjectAccount(${projectDir}, ${email})`);
  122. const activeAccounts = configstore_1.configstore.get("activeAccounts") || {};
  123. activeAccounts[projectDir] = email;
  124. configstore_1.configstore.set("activeAccounts", activeAccounts);
  125. }
  126. exports.setProjectAccount = setProjectAccount;
  127. function setGlobalDefaultAccount(account) {
  128. configstore_1.configstore.set("user", account.user);
  129. configstore_1.configstore.set("tokens", account.tokens);
  130. const additionalAccounts = getAdditionalAccounts();
  131. const index = additionalAccounts.findIndex((a) => a.user.email === account.user.email);
  132. if (index >= 0) {
  133. additionalAccounts.splice(index, 1);
  134. configstore_1.configstore.set("additionalAccounts", additionalAccounts);
  135. }
  136. }
  137. exports.setGlobalDefaultAccount = setGlobalDefaultAccount;
  138. function open(url) {
  139. opn(url).catch((err) => {
  140. logger_1.logger.debug("Unable to open URL: " + err.stack);
  141. });
  142. }
  143. function invalidCredentialError() {
  144. return new error_1.FirebaseError("Authentication Error: Your credentials are no longer valid. Please run " +
  145. clc.bold("firebase login --reauth") +
  146. "\n\n" +
  147. "For CI servers and headless environments, generate a new token with " +
  148. clc.bold("firebase login:ci"), { exit: 1 });
  149. }
  150. const FIFTEEN_MINUTES_IN_MS = 15 * 60 * 1000;
  151. const SCOPES = [
  152. scopes.EMAIL,
  153. scopes.OPENID,
  154. scopes.CLOUD_PROJECTS_READONLY,
  155. scopes.FIREBASE_PLATFORM,
  156. scopes.CLOUD_PLATFORM,
  157. ];
  158. const _nonce = Math.floor(Math.random() * (2 << 29) + 1).toString();
  159. const getPort = portfinder.getPortPromise;
  160. let lastAccessToken;
  161. function getCallbackUrl(port) {
  162. if (typeof port === "undefined") {
  163. return "urn:ietf:wg:oauth:2.0:oob";
  164. }
  165. return `http://localhost:${port}`;
  166. }
  167. function queryParamString(args) {
  168. const tokens = [];
  169. for (const [key, value] of Object.entries(args)) {
  170. if (typeof value === "string") {
  171. tokens.push(key + "=" + encodeURIComponent(value));
  172. }
  173. }
  174. return tokens.join("&");
  175. }
  176. function getLoginUrl(callbackUrl, userHint) {
  177. return (api_1.authOrigin +
  178. "/o/oauth2/auth?" +
  179. queryParamString({
  180. client_id: api_1.clientId,
  181. scope: SCOPES.join(" "),
  182. response_type: "code",
  183. state: _nonce,
  184. redirect_uri: callbackUrl,
  185. login_hint: userHint,
  186. }));
  187. }
  188. async function getTokensFromAuthorizationCode(code, callbackUrl, verifier) {
  189. const params = {
  190. code: code,
  191. client_id: api_1.clientId,
  192. client_secret: api_1.clientSecret,
  193. redirect_uri: callbackUrl,
  194. grant_type: "authorization_code",
  195. };
  196. if (verifier) {
  197. params["code_verifier"] = verifier;
  198. }
  199. let res;
  200. try {
  201. const client = new apiv2.Client({ urlPrefix: api_1.authOrigin, auth: false });
  202. const form = new FormData();
  203. for (const [k, v] of Object.entries(params)) {
  204. form.append(k, v);
  205. }
  206. res = await client.request({
  207. method: "POST",
  208. path: "/o/oauth2/token",
  209. body: form,
  210. headers: form.getHeaders(),
  211. skipLog: { body: true, queryParams: true, resBody: true },
  212. });
  213. }
  214. catch (err) {
  215. if (err instanceof Error) {
  216. logger_1.logger.debug("Token Fetch Error:", err.stack || "");
  217. }
  218. else {
  219. logger_1.logger.debug("Token Fetch Error");
  220. }
  221. throw invalidCredentialError();
  222. }
  223. if (!res.body.access_token && !res.body.refresh_token) {
  224. logger_1.logger.debug("Token Fetch Error:", res.status, res.body);
  225. throw invalidCredentialError();
  226. }
  227. lastAccessToken = Object.assign({
  228. expires_at: Date.now() + res.body.expires_in * 1000,
  229. }, res.body);
  230. return lastAccessToken;
  231. }
  232. const GITHUB_SCOPES = ["read:user", "repo", "public_repo"];
  233. function getGithubLoginUrl(callbackUrl) {
  234. return (api_1.githubOrigin +
  235. "/login/oauth/authorize?" +
  236. queryParamString({
  237. client_id: api_1.githubClientId,
  238. state: _nonce,
  239. redirect_uri: callbackUrl,
  240. scope: GITHUB_SCOPES.join(" "),
  241. }));
  242. }
  243. async function getGithubTokensFromAuthorizationCode(code, callbackUrl) {
  244. const client = new apiv2.Client({ urlPrefix: api_1.githubOrigin, auth: false });
  245. const data = {
  246. client_id: api_1.githubClientId,
  247. client_secret: api_1.githubClientSecret,
  248. code,
  249. redirect_uri: callbackUrl,
  250. state: _nonce,
  251. };
  252. const form = new FormData();
  253. for (const [k, v] of Object.entries(data)) {
  254. form.append(k, v);
  255. }
  256. const headers = form.getHeaders();
  257. headers.accept = "application/json";
  258. const res = await client.request({
  259. method: "POST",
  260. path: "/login/oauth/access_token",
  261. body: form,
  262. headers,
  263. });
  264. return res.body.access_token;
  265. }
  266. async function respondWithFile(req, res, statusCode, filename) {
  267. const response = await util.promisify(fs.readFile)(path.join(__dirname, filename));
  268. res.writeHead(statusCode, {
  269. "Content-Length": response.length,
  270. "Content-Type": "text/html",
  271. });
  272. res.end(response);
  273. req.socket.destroy();
  274. }
  275. function urlsafeBase64(base64string) {
  276. return base64string.replace(/\+/g, "-").replace(/=+$/, "").replace(/\//g, "_");
  277. }
  278. async function loginRemotely() {
  279. var _a;
  280. const authProxyClient = new apiv2.Client({
  281. urlPrefix: api_1.authProxyOrigin,
  282. auth: false,
  283. });
  284. const sessionId = (0, uuid_1.v4)();
  285. const codeVerifier = (0, crypto_1.randomBytes)(32).toString("hex");
  286. const codeChallenge = urlsafeBase64((0, crypto_1.createHash)("sha256").update(codeVerifier).digest("base64"));
  287. const attestToken = (_a = (await authProxyClient.post("/attest", {
  288. session_id: sessionId,
  289. })).body) === null || _a === void 0 ? void 0 : _a.token;
  290. const loginUrl = `${api_1.authProxyOrigin}/login?code_challenge=${codeChallenge}&session=${sessionId}&attest=${attestToken}`;
  291. logger_1.logger.info();
  292. logger_1.logger.info("To sign in to the Firebase CLI:");
  293. logger_1.logger.info();
  294. logger_1.logger.info("1. Take note of your session ID:");
  295. logger_1.logger.info();
  296. logger_1.logger.info(` ${clc.bold(sessionId.substring(0, 5).toUpperCase())}`);
  297. logger_1.logger.info();
  298. logger_1.logger.info("2. Visit the URL below on any device and follow the instructions to get your code:");
  299. logger_1.logger.info();
  300. logger_1.logger.info(` ${loginUrl}`);
  301. logger_1.logger.info();
  302. logger_1.logger.info("3. Paste or enter the authorization code below once you have it:");
  303. logger_1.logger.info();
  304. const code = await (0, prompt_1.promptOnce)({
  305. type: "input",
  306. message: "Enter authorization code:",
  307. });
  308. try {
  309. const tokens = await getTokensFromAuthorizationCode(code, `${api_1.authProxyOrigin}/complete`, codeVerifier);
  310. void (0, track_1.track)("login", "google_remote");
  311. return {
  312. user: jwt.decode(tokens.id_token),
  313. tokens: tokens,
  314. scopes: SCOPES,
  315. };
  316. }
  317. catch (e) {
  318. throw new error_1.FirebaseError("Unable to authenticate using the provided code. Please try again.");
  319. }
  320. }
  321. async function loginWithLocalhostGoogle(port, userHint) {
  322. const callbackUrl = getCallbackUrl(port);
  323. const authUrl = getLoginUrl(callbackUrl, userHint);
  324. const successTemplate = "../templates/loginSuccess.html";
  325. const tokens = await loginWithLocalhost(port, callbackUrl, authUrl, successTemplate, getTokensFromAuthorizationCode);
  326. void (0, track_1.track)("login", "google_localhost");
  327. return {
  328. user: jwt.decode(tokens.id_token),
  329. tokens: tokens,
  330. scopes: tokens.scopes,
  331. };
  332. }
  333. async function loginWithLocalhostGitHub(port) {
  334. const callbackUrl = getCallbackUrl(port);
  335. const authUrl = getGithubLoginUrl(callbackUrl);
  336. const successTemplate = "../templates/loginSuccessGithub.html";
  337. const tokens = await loginWithLocalhost(port, callbackUrl, authUrl, successTemplate, getGithubTokensFromAuthorizationCode);
  338. void (0, track_1.track)("login", "google_localhost");
  339. return tokens;
  340. }
  341. async function loginWithLocalhost(port, callbackUrl, authUrl, successTemplate, getTokens) {
  342. return new Promise((resolve, reject) => {
  343. const server = http.createServer(async (req, res) => {
  344. const query = url.parse(`${req.url}`, true).query || {};
  345. const queryState = query.state;
  346. const queryCode = query.code;
  347. if (queryState !== _nonce || typeof queryCode !== "string") {
  348. await respondWithFile(req, res, 400, "../templates/loginFailure.html");
  349. reject(new error_1.FirebaseError("Unexpected error while logging in"));
  350. server.close();
  351. return;
  352. }
  353. try {
  354. const tokens = await getTokens(queryCode, callbackUrl);
  355. await respondWithFile(req, res, 200, successTemplate);
  356. resolve(tokens);
  357. }
  358. catch (err) {
  359. await respondWithFile(req, res, 400, "../templates/loginFailure.html");
  360. reject(err);
  361. }
  362. server.close();
  363. return;
  364. });
  365. server.listen(port, () => {
  366. logger_1.logger.info();
  367. logger_1.logger.info("Visit this URL on this device to log in:");
  368. logger_1.logger.info(clc.bold(clc.underline(authUrl)));
  369. logger_1.logger.info();
  370. logger_1.logger.info("Waiting for authentication...");
  371. open(authUrl);
  372. });
  373. server.on("error", (err) => {
  374. reject(err);
  375. });
  376. });
  377. }
  378. async function loginGoogle(localhost, userHint) {
  379. if (localhost) {
  380. try {
  381. const port = await getPort();
  382. return await loginWithLocalhostGoogle(port, userHint);
  383. }
  384. catch (_a) {
  385. return await loginRemotely();
  386. }
  387. }
  388. return await loginRemotely();
  389. }
  390. exports.loginGoogle = loginGoogle;
  391. async function loginGithub() {
  392. const port = await getPort();
  393. return loginWithLocalhostGitHub(port);
  394. }
  395. exports.loginGithub = loginGithub;
  396. function findAccountByEmail(email) {
  397. return getAllAccounts().find((a) => a.user.email === email);
  398. }
  399. exports.findAccountByEmail = findAccountByEmail;
  400. function haveValidTokens(refreshToken, authScopes) {
  401. var _a;
  402. if (!(lastAccessToken === null || lastAccessToken === void 0 ? void 0 : lastAccessToken.access_token)) {
  403. const tokens = configstore_1.configstore.get("tokens");
  404. if (refreshToken === (tokens === null || tokens === void 0 ? void 0 : tokens.refresh_token)) {
  405. lastAccessToken = tokens;
  406. }
  407. }
  408. const hasTokens = !!(lastAccessToken === null || lastAccessToken === void 0 ? void 0 : lastAccessToken.access_token);
  409. const oldScopesJSON = JSON.stringify(((_a = lastAccessToken === null || lastAccessToken === void 0 ? void 0 : lastAccessToken.scopes) === null || _a === void 0 ? void 0 : _a.sort()) || []);
  410. const newScopesJSON = JSON.stringify(authScopes.sort());
  411. const hasSameScopes = oldScopesJSON === newScopesJSON;
  412. const isExpired = ((lastAccessToken === null || lastAccessToken === void 0 ? void 0 : lastAccessToken.expires_at) || 0) < Date.now() + FIFTEEN_MINUTES_IN_MS;
  413. return hasTokens && hasSameScopes && !isExpired;
  414. }
  415. function deleteAccount(account) {
  416. const defaultAccount = getGlobalDefaultAccount();
  417. if (account.user.email === (defaultAccount === null || defaultAccount === void 0 ? void 0 : defaultAccount.user.email)) {
  418. configstore_1.configstore.delete("user");
  419. configstore_1.configstore.delete("tokens");
  420. configstore_1.configstore.delete("usage");
  421. configstore_1.configstore.delete("analytics-uuid");
  422. }
  423. const additionalAccounts = getAdditionalAccounts();
  424. const remainingAccounts = additionalAccounts.filter((a) => a.user.email !== account.user.email);
  425. configstore_1.configstore.set("additionalAccounts", remainingAccounts);
  426. const activeAccounts = configstore_1.configstore.get("activeAccounts") || {};
  427. for (const [projectDir, projectAccount] of Object.entries(activeAccounts)) {
  428. if (projectAccount === account.user.email) {
  429. delete activeAccounts[projectDir];
  430. }
  431. }
  432. configstore_1.configstore.set("activeAccounts", activeAccounts);
  433. }
  434. function updateAccount(account) {
  435. const defaultAccount = getGlobalDefaultAccount();
  436. if (account.user.email === (defaultAccount === null || defaultAccount === void 0 ? void 0 : defaultAccount.user.email)) {
  437. configstore_1.configstore.set("user", account.user);
  438. configstore_1.configstore.set("tokens", account.tokens);
  439. }
  440. const additionalAccounts = getAdditionalAccounts();
  441. const accountIndex = additionalAccounts.findIndex((a) => a.user.email === account.user.email);
  442. if (accountIndex >= 0) {
  443. additionalAccounts.splice(accountIndex, 1, account);
  444. configstore_1.configstore.set("additionalAccounts", additionalAccounts);
  445. }
  446. }
  447. function findAccountByRefreshToken(refreshToken) {
  448. return getAllAccounts().find((a) => a.tokens.refresh_token === refreshToken);
  449. }
  450. function logoutCurrentSession(refreshToken) {
  451. const account = findAccountByRefreshToken(refreshToken);
  452. if (!account) {
  453. return;
  454. }
  455. (0, defaultCredentials_1.clearCredentials)(account);
  456. deleteAccount(account);
  457. }
  458. async function refreshTokens(refreshToken, authScopes) {
  459. var _a, _b;
  460. logger_1.logger.debug("> refreshing access token with scopes:", JSON.stringify(authScopes));
  461. try {
  462. const client = new apiv2.Client({ urlPrefix: api_1.googleOrigin, auth: false });
  463. const data = {
  464. refresh_token: refreshToken,
  465. client_id: api_1.clientId,
  466. client_secret: api_1.clientSecret,
  467. grant_type: "refresh_token",
  468. scope: (authScopes || []).join(" "),
  469. };
  470. const form = new FormData();
  471. for (const [k, v] of Object.entries(data)) {
  472. form.append(k, v);
  473. }
  474. const res = await client.request({
  475. method: "POST",
  476. path: "/oauth2/v3/token",
  477. body: form,
  478. headers: form.getHeaders(),
  479. skipLog: { body: true, queryParams: true, resBody: true },
  480. resolveOnHTTPError: true,
  481. });
  482. if (res.status === 401 || res.status === 400) {
  483. return { access_token: refreshToken };
  484. }
  485. if (typeof res.body.access_token !== "string") {
  486. throw invalidCredentialError();
  487. }
  488. lastAccessToken = Object.assign({
  489. expires_at: Date.now() + res.body.expires_in * 1000,
  490. refresh_token: refreshToken,
  491. scopes: authScopes,
  492. }, res.body);
  493. const account = findAccountByRefreshToken(refreshToken);
  494. if (account && lastAccessToken) {
  495. account.tokens = lastAccessToken;
  496. updateAccount(account);
  497. }
  498. return lastAccessToken;
  499. }
  500. catch (err) {
  501. if (((_b = (_a = err === null || err === void 0 ? void 0 : err.context) === null || _a === void 0 ? void 0 : _a.body) === null || _b === void 0 ? void 0 : _b.error) === "invalid_scope") {
  502. throw new error_1.FirebaseError("This command requires new authorization scopes not granted to your current session. Please run " +
  503. clc.bold("firebase login --reauth") +
  504. "\n\n" +
  505. "For CI servers and headless environments, generate a new token with " +
  506. clc.bold("firebase login:ci"), { exit: 1 });
  507. }
  508. throw invalidCredentialError();
  509. }
  510. }
  511. async function getAccessToken(refreshToken, authScopes) {
  512. if (haveValidTokens(refreshToken, authScopes)) {
  513. return lastAccessToken;
  514. }
  515. return refreshTokens(refreshToken, authScopes);
  516. }
  517. exports.getAccessToken = getAccessToken;
  518. async function logout(refreshToken) {
  519. if ((lastAccessToken === null || lastAccessToken === void 0 ? void 0 : lastAccessToken.refresh_token) === refreshToken) {
  520. lastAccessToken = undefined;
  521. }
  522. logoutCurrentSession(refreshToken);
  523. try {
  524. const client = new apiv2.Client({ urlPrefix: api_1.authOrigin, auth: false });
  525. await client.get("/o/oauth2/revoke", { queryParams: { token: refreshToken } });
  526. }
  527. catch (thrown) {
  528. const err = thrown instanceof Error ? thrown : new Error(thrown);
  529. throw new error_1.FirebaseError("Authentication Error.", {
  530. exit: 1,
  531. original: err,
  532. });
  533. }
  534. }
  535. exports.logout = logout;