No Description
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.

server.js 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. "use strict";
  2. Object.defineProperty(exports, "__esModule", { value: true });
  3. exports.createApp = void 0;
  4. const cors = require("cors");
  5. const express = require("express");
  6. const exegesisExpress = require("exegesis-express");
  7. const errors_1 = require("exegesis/lib/errors");
  8. const _ = require("lodash");
  9. const index_1 = require("./index");
  10. const emulatorLogger_1 = require("../emulatorLogger");
  11. const types_1 = require("../types");
  12. const operations_1 = require("./operations");
  13. const state_1 = require("./state");
  14. const apiSpec_1 = require("./apiSpec");
  15. const errors_2 = require("./errors");
  16. const utils_1 = require("./utils");
  17. const lodash_1 = require("lodash");
  18. const handlers_1 = require("./handlers");
  19. const bodyParser = require("body-parser");
  20. const url_1 = require("url");
  21. const jsonwebtoken_1 = require("jsonwebtoken");
  22. const apiSpec = apiSpec_1.default;
  23. const API_SPEC_PATH = "/emulator/openapi.json";
  24. const AUTH_HEADER_PREFIX = "bearer ";
  25. const SERVICE_ACCOUNT_TOKEN_PREFIX = "ya29.";
  26. function specForRouter() {
  27. const paths = {};
  28. Object.entries(apiSpec.paths).forEach(([path, pathObj]) => {
  29. var _a;
  30. const servers = (_a = pathObj.servers) !== null && _a !== void 0 ? _a : apiSpec.servers;
  31. if (!servers || !servers.length) {
  32. throw new Error("No servers defined in API spec.");
  33. }
  34. const pathWithNamespace = servers[0].url.replace("https://", "/") + path;
  35. paths[pathWithNamespace] = pathObj;
  36. });
  37. return Object.assign(Object.assign({}, apiSpec), { paths, servers: undefined, "x-exegesis-controller": "auth" });
  38. }
  39. function specWithEmulatorServer(protocol, host) {
  40. const paths = {};
  41. Object.entries(apiSpec.paths).forEach(([path, pathObj]) => {
  42. const servers = pathObj.servers;
  43. if (servers) {
  44. pathObj = Object.assign(Object.assign({}, pathObj), { servers: serversWithEmulators(servers) });
  45. }
  46. paths[path] = pathObj;
  47. });
  48. if (!apiSpec.servers) {
  49. throw new Error("No servers defined in API spec.");
  50. }
  51. return Object.assign(Object.assign({}, apiSpec), { servers: serversWithEmulators(apiSpec.servers), paths });
  52. function serversWithEmulators(servers) {
  53. const result = [];
  54. for (const server of servers) {
  55. result.push({
  56. url: server.url ? server.url.replace("https://", "{EMULATOR}/") : "{EMULATOR}",
  57. variables: {
  58. EMULATOR: {
  59. default: host ? `${protocol}://${host}` : "",
  60. description: "The protocol, hostname, and port of Firebase Auth Emulator.",
  61. },
  62. },
  63. });
  64. if (server.url) {
  65. result.push(server);
  66. }
  67. }
  68. return result;
  69. }
  70. }
  71. async function createApp(defaultProjectId, singleProjectMode = index_1.SingleProjectMode.NO_WARNING, projectStateForId = new Map()) {
  72. const app = express();
  73. app.set("json spaces", 2);
  74. app.use("/", (req, res, next) => {
  75. if (req.headers["access-control-request-private-network"]) {
  76. res.setHeader("access-control-allow-private-network", "true");
  77. }
  78. next();
  79. });
  80. app.use(cors({ origin: true }));
  81. app.delete("*", (req, _, next) => {
  82. delete req.headers["content-type"];
  83. next();
  84. });
  85. app.get("/", (req, res) => {
  86. return res.json({
  87. authEmulator: {
  88. ready: true,
  89. docs: "https://firebase.google.com/docs/emulator-suite",
  90. apiSpec: API_SPEC_PATH,
  91. },
  92. });
  93. });
  94. app.get(API_SPEC_PATH, (req, res) => {
  95. res.json(specWithEmulatorServer(req.protocol, req.headers.host));
  96. });
  97. registerLegacyRoutes(app);
  98. (0, handlers_1.registerHandlers)(app, (apiKey, tenantId) => getProjectStateById(getProjectIdByApiKey(apiKey), tenantId));
  99. const apiKeyAuthenticator = (ctx, info) => {
  100. if (!info.name) {
  101. throw new Error("apiKey param/header name is undefined in API spec.");
  102. }
  103. let key;
  104. const req = ctx.req;
  105. switch (info.in) {
  106. case "header":
  107. key = req.get(info.name);
  108. break;
  109. case "query": {
  110. const q = req.query[info.name];
  111. key = typeof q === "string" ? q : undefined;
  112. break;
  113. }
  114. default:
  115. throw new Error('apiKey must be defined as in: "query" or "header" in API spec.');
  116. }
  117. if (key) {
  118. return { type: "success", user: getProjectIdByApiKey(key) };
  119. }
  120. else {
  121. return undefined;
  122. }
  123. };
  124. const oauth2Authenticator = (ctx) => {
  125. const authorization = ctx.req.headers["authorization"];
  126. if (!authorization || !authorization.toLowerCase().startsWith(AUTH_HEADER_PREFIX)) {
  127. return undefined;
  128. }
  129. const scopes = Object.keys(ctx.api.openApiDoc.components.securitySchemes.Oauth2.flows.authorizationCode.scopes);
  130. const token = authorization.substr(AUTH_HEADER_PREFIX.length);
  131. if (token.toLowerCase() === "owner") {
  132. return { type: "success", user: defaultProjectId, scopes };
  133. }
  134. else if (token.startsWith(SERVICE_ACCOUNT_TOKEN_PREFIX)) {
  135. emulatorLogger_1.EmulatorLogger.forEmulator(types_1.Emulators.AUTH).log("WARN", `Received service account token ${token}. Assuming that it owns project "${defaultProjectId}".`);
  136. return { type: "success", user: defaultProjectId, scopes };
  137. }
  138. throw new errors_2.UnauthenticatedError("Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.", [
  139. {
  140. message: "Invalid Credentials",
  141. domain: "global",
  142. reason: "authError",
  143. location: "Authorization",
  144. locationType: "header",
  145. },
  146. ]);
  147. };
  148. const apis = await exegesisExpress.middleware(specForRouter(), {
  149. controllers: { auth: toExegesisController(operations_1.authOperations, getProjectStateById) },
  150. authenticators: {
  151. apiKeyQuery: apiKeyAuthenticator,
  152. apiKeyHeader: apiKeyAuthenticator,
  153. Oauth2: oauth2Authenticator,
  154. },
  155. autoHandleHttpErrors(err) {
  156. if (err.type === "entity.parse.failed") {
  157. const message = `Invalid JSON payload received. ${err.message}`;
  158. err = new errors_2.InvalidArgumentError(message, [
  159. {
  160. message,
  161. domain: "global",
  162. reason: "parseError",
  163. },
  164. ]);
  165. }
  166. if (err instanceof errors_1.ValidationError) {
  167. const firstError = err.errors[0];
  168. let details;
  169. if (firstError.location) {
  170. details = `${firstError.location.path} ${firstError.message}`;
  171. }
  172. else {
  173. details = firstError.message;
  174. }
  175. err = new errors_2.InvalidArgumentError(`Invalid JSON payload received. ${details}`);
  176. }
  177. if (err.name === "HttpBadRequestError") {
  178. err = new errors_2.BadRequestError(err.message, "unknown");
  179. }
  180. throw err;
  181. },
  182. defaultMaxBodySize: 1024 * 1024 * 1024,
  183. validateDefaultResponses: true,
  184. onResponseValidationError({ errors }) {
  185. (0, utils_1.logError)(new Error(`An internal error occured when generating response. Details:\n${JSON.stringify(errors)}`));
  186. throw new errors_2.InternalError("An internal error occured when generating response.", "emulator-response-validation");
  187. },
  188. customFormats: {
  189. "google-datetime"() {
  190. return true;
  191. },
  192. "google-fieldmask"() {
  193. return true;
  194. },
  195. "google-duration"() {
  196. return true;
  197. },
  198. uint64() {
  199. return true;
  200. },
  201. uint32() {
  202. return true;
  203. },
  204. byte() {
  205. return true;
  206. },
  207. },
  208. plugins: [
  209. {
  210. info: { name: "test" },
  211. makeExegesisPlugin() {
  212. return {
  213. postSecurity(pluginContext) {
  214. wrapValidateBody(pluginContext);
  215. return Promise.resolve();
  216. },
  217. postController(ctx) {
  218. if (ctx.res.statusCode === 401) {
  219. const requirements = ctx.api.operationObject.security;
  220. if (requirements === null || requirements === void 0 ? void 0 : requirements.some((req) => req.apiKeyQuery || req.apiKeyHeader)) {
  221. throw new errors_2.PermissionDeniedError("The request is missing a valid API key.");
  222. }
  223. else {
  224. throw new errors_2.UnauthenticatedError("Request is missing required authentication credential. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.", [
  225. {
  226. message: "Login Required.",
  227. domain: "global",
  228. reason: "required",
  229. location: "Authorization",
  230. locationType: "header",
  231. },
  232. ]);
  233. }
  234. }
  235. },
  236. };
  237. },
  238. },
  239. ],
  240. });
  241. app.use(apis);
  242. app.use(() => {
  243. throw new errors_2.NotFoundError();
  244. });
  245. app.use(((err, req, res, next) => {
  246. let apiError;
  247. if (err instanceof errors_2.ApiError) {
  248. apiError = err;
  249. }
  250. else if (!err.status || err.status === 500) {
  251. apiError = new errors_2.UnknownError(err.message || "Unknown error", err.name || "unknown");
  252. }
  253. else {
  254. return res.status(err.status).json(err);
  255. }
  256. if (apiError.code === 500) {
  257. (0, utils_1.logError)(err);
  258. }
  259. return res.status(apiError.code).json({ error: apiError });
  260. }));
  261. return app;
  262. function getProjectIdByApiKey(apiKey) {
  263. apiKey;
  264. return defaultProjectId;
  265. }
  266. function getProjectStateById(projectId, tenantId) {
  267. let agentState = projectStateForId.get(projectId);
  268. if (singleProjectMode !== index_1.SingleProjectMode.NO_WARNING &&
  269. projectId &&
  270. defaultProjectId !== projectId) {
  271. const errorString = `Multiple projectIds are not recommended in single project mode. ` +
  272. `Requested project ID ${projectId}, but the emulator is configured for ` +
  273. `${defaultProjectId}. To opt-out of single project mode add/set the ` +
  274. `\'"singleProjectMode"\' false' property in the firebase.json emulators config.`;
  275. emulatorLogger_1.EmulatorLogger.forEmulator(types_1.Emulators.AUTH).log("WARN", errorString);
  276. if (singleProjectMode === index_1.SingleProjectMode.ERROR) {
  277. throw new errors_2.BadRequestError(errorString);
  278. }
  279. }
  280. if (!agentState) {
  281. agentState = new state_1.AgentProjectState(projectId);
  282. projectStateForId.set(projectId, agentState);
  283. }
  284. if (!tenantId) {
  285. return agentState;
  286. }
  287. return agentState.getTenantProject(tenantId);
  288. }
  289. }
  290. exports.createApp = createApp;
  291. function registerLegacyRoutes(app) {
  292. const relyingPartyPrefix = "/www.googleapis.com/identitytoolkit/v3/relyingparty/";
  293. const v1Prefix = "/identitytoolkit.googleapis.com/v1/";
  294. for (const [oldPath, newPath] of [
  295. ["createAuthUri", "accounts:createAuthUri"],
  296. ["deleteAccount", "accounts:delete"],
  297. ["emailLinkSignin", "accounts:signInWithEmailLink"],
  298. ["getAccountInfo", "accounts:lookup"],
  299. ["getOobConfirmationCode", "accounts:sendOobCode"],
  300. ["getProjectConfig", "projects"],
  301. ["getRecaptchaParam", "recaptchaParams"],
  302. ["publicKeys", "publicKeys"],
  303. ["resetPassword", "accounts:resetPassword"],
  304. ["sendVerificationCode", "accounts:sendVerificationCode"],
  305. ["setAccountInfo", "accounts:update"],
  306. ["setProjectConfig", "setProjectConfig"],
  307. ["signupNewUser", "accounts:signUp"],
  308. ["verifyAssertion", "accounts:signInWithIdp"],
  309. ["verifyCustomToken", "accounts:signInWithCustomToken"],
  310. ["verifyPassword", "accounts:signInWithPassword"],
  311. ["verifyPhoneNumber", "accounts:signInWithPhoneNumber"],
  312. ]) {
  313. app.all(`${relyingPartyPrefix}${oldPath}`, (req, _, next) => {
  314. req.url = `${v1Prefix}${newPath}`;
  315. next();
  316. });
  317. }
  318. app.post(`${relyingPartyPrefix}signOutUser`, () => {
  319. throw new errors_2.NotImplementedError(`signOutUser is not implemented in the Auth Emulator.`);
  320. });
  321. for (const [oldPath, newMethod, newPath] of [
  322. ["downloadAccount", "GET", "accounts:batchGet"],
  323. ["uploadAccount", "POST", "accounts:batchCreate"],
  324. ]) {
  325. app.post(`${relyingPartyPrefix}${oldPath}`, bodyParser.json(), (req, res, next) => {
  326. req.body = convertKeysToCamelCase(req.body || {});
  327. const targetProjectId = req.body.targetProjectId;
  328. if (!targetProjectId) {
  329. return next(new errors_2.BadRequestError("INSUFFICIENT_PERMISSION"));
  330. }
  331. delete req.body.targetProjectId;
  332. req.method = newMethod;
  333. let qs = req.url.split("?", 2)[1] || "";
  334. if (newMethod === "GET") {
  335. Object.assign(req.query, req.body);
  336. const bodyAsQuery = new url_1.URLSearchParams(req.body).toString();
  337. qs = qs ? `${qs}&${bodyAsQuery}` : bodyAsQuery;
  338. delete req.body;
  339. delete req.headers["content-type"];
  340. }
  341. req.url = `${v1Prefix}projects/${encodeURIComponent(targetProjectId)}/${newPath}?${qs}`;
  342. next();
  343. });
  344. }
  345. }
  346. function toExegesisController(ops, getProjectStateById) {
  347. const result = {};
  348. processNested(ops, "");
  349. return new Proxy(result, {
  350. get: (obj, prop) => {
  351. if (typeof prop !== "string" || prop in obj) {
  352. return obj[prop];
  353. }
  354. const stub = () => {
  355. throw new errors_2.NotImplementedError(`${prop} is not implemented in the Auth Emulator.`);
  356. };
  357. return stub;
  358. },
  359. });
  360. function processNested(nested, prefix) {
  361. Object.entries(nested).forEach(([key, value]) => {
  362. if (typeof value === "function") {
  363. result[`${prefix}${key}`] = toExegesisOperation(value);
  364. }
  365. else {
  366. processNested(value, `${prefix}${key}.`);
  367. if (typeof value._ === "function") {
  368. result[`${prefix}${key}`] = toExegesisOperation(value._);
  369. }
  370. }
  371. });
  372. }
  373. function toExegesisOperation(operation) {
  374. return (ctx) => {
  375. var _a, _b, _c, _d, _e, _f, _g, _h;
  376. let targetProjectId = ctx.params.path.targetProjectId || ((_a = ctx.requestBody) === null || _a === void 0 ? void 0 : _a.targetProjectId);
  377. if (targetProjectId) {
  378. if ((_b = ctx.api.operationObject.security) === null || _b === void 0 ? void 0 : _b.some((sec) => sec.Oauth2)) {
  379. (0, errors_2.assert)((_c = ctx.security) === null || _c === void 0 ? void 0 : _c.Oauth2, "INSUFFICIENT_PERMISSION : Only authenticated requests can specify target_project_id.");
  380. }
  381. }
  382. else {
  383. targetProjectId = ctx.user;
  384. }
  385. let targetTenantId = undefined;
  386. if (ctx.params.path.tenantId && ((_d = ctx.requestBody) === null || _d === void 0 ? void 0 : _d.tenantId)) {
  387. (0, errors_2.assert)(ctx.params.path.tenantId === ctx.requestBody.tenantId, "TENANT_ID_MISMATCH");
  388. }
  389. targetTenantId = ctx.params.path.tenantId || ((_e = ctx.requestBody) === null || _e === void 0 ? void 0 : _e.tenantId);
  390. if ((_f = ctx.requestBody) === null || _f === void 0 ? void 0 : _f.idToken) {
  391. const idToken = (_g = ctx.requestBody) === null || _g === void 0 ? void 0 : _g.idToken;
  392. const decoded = (0, jsonwebtoken_1.decode)(idToken, { complete: true });
  393. if ((decoded === null || decoded === void 0 ? void 0 : decoded.payload.firebase.tenant) && targetTenantId) {
  394. (0, errors_2.assert)((decoded === null || decoded === void 0 ? void 0 : decoded.payload.firebase.tenant) === targetTenantId, "TENANT_ID_MISMATCH");
  395. }
  396. targetTenantId = targetTenantId || (decoded === null || decoded === void 0 ? void 0 : decoded.payload.firebase.tenant);
  397. }
  398. if ((_h = ctx.requestBody) === null || _h === void 0 ? void 0 : _h.refreshToken) {
  399. const refreshTokenRecord = (0, state_1.decodeRefreshToken)(ctx.requestBody.refreshToken);
  400. if (refreshTokenRecord.tenantId && targetTenantId) {
  401. (0, errors_2.assert)(refreshTokenRecord.tenantId === targetTenantId, "TENANT_ID_MISMATCH: ((Refresh token tenant ID does not match target tenant ID.))");
  402. }
  403. targetTenantId = targetTenantId || refreshTokenRecord.tenantId;
  404. }
  405. return operation(getProjectStateById(targetProjectId, targetTenantId), ctx.requestBody, ctx);
  406. };
  407. }
  408. }
  409. function wrapValidateBody(pluginContext) {
  410. const op = pluginContext._operation;
  411. if (op.validateBody && !op._authEmulatorValidateBodyWrapped) {
  412. const validateBody = op.validateBody.bind(op);
  413. op.validateBody = (body) => {
  414. return validateAndFixRestMappingRequestBody(validateBody, body);
  415. };
  416. op._authEmulatorValidateBodyWrapped = true;
  417. }
  418. }
  419. function validateAndFixRestMappingRequestBody(validate, body) {
  420. var _a;
  421. body = convertKeysToCamelCase(body);
  422. let result;
  423. let keepFixing = false;
  424. const fixedPaths = new Set();
  425. do {
  426. result = validate(body);
  427. if (!result.errors)
  428. return result;
  429. keepFixing = false;
  430. for (const error of result.errors) {
  431. const path = (_a = error.location) === null || _a === void 0 ? void 0 : _a.path;
  432. const ajvError = error.ajvError;
  433. if (!path || fixedPaths.has(path) || !ajvError) {
  434. continue;
  435. }
  436. const dataPath = jsonPointerToPath(path);
  437. const value = _.get(body, dataPath);
  438. if (ajvError.keyword === "type" && ajvError.params.type === "string") {
  439. if (typeof value === "number") {
  440. _.set(body, dataPath, value.toString());
  441. keepFixing = true;
  442. }
  443. }
  444. else if (ajvError.keyword === "enum") {
  445. const params = ajvError.params;
  446. const enumValue = params.allowedValues[value];
  447. if (enumValue) {
  448. _.set(body, dataPath, enumValue);
  449. keepFixing = true;
  450. }
  451. }
  452. }
  453. } while (keepFixing);
  454. return result;
  455. }
  456. function convertKeysToCamelCase(body) {
  457. if (body == null || typeof body !== "object")
  458. return body;
  459. if (Array.isArray(body)) {
  460. return body.map(convertKeysToCamelCase);
  461. }
  462. const result = Object.create(null);
  463. for (const key of Object.keys(body)) {
  464. result[(0, lodash_1.camelCase)(key)] = convertKeysToCamelCase(body[key]);
  465. }
  466. return result;
  467. }
  468. function jsonPointerToPath(pointer) {
  469. const path = pointer.split("/").map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
  470. if (path[0] === "#" || path[0] === "") {
  471. path.shift();
  472. }
  473. return path;
  474. }