"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createApp = void 0; const cors = require("cors"); const express = require("express"); const exegesisExpress = require("exegesis-express"); const errors_1 = require("exegesis/lib/errors"); const _ = require("lodash"); const index_1 = require("./index"); const emulatorLogger_1 = require("../emulatorLogger"); const types_1 = require("../types"); const operations_1 = require("./operations"); const state_1 = require("./state"); const apiSpec_1 = require("./apiSpec"); const errors_2 = require("./errors"); const utils_1 = require("./utils"); const lodash_1 = require("lodash"); const handlers_1 = require("./handlers"); const bodyParser = require("body-parser"); const url_1 = require("url"); const jsonwebtoken_1 = require("jsonwebtoken"); const apiSpec = apiSpec_1.default; const API_SPEC_PATH = "/emulator/openapi.json"; const AUTH_HEADER_PREFIX = "bearer "; const SERVICE_ACCOUNT_TOKEN_PREFIX = "ya29."; function specForRouter() { const paths = {}; Object.entries(apiSpec.paths).forEach(([path, pathObj]) => { var _a; const servers = (_a = pathObj.servers) !== null && _a !== void 0 ? _a : apiSpec.servers; if (!servers || !servers.length) { throw new Error("No servers defined in API spec."); } const pathWithNamespace = servers[0].url.replace("https://", "/") + path; paths[pathWithNamespace] = pathObj; }); return Object.assign(Object.assign({}, apiSpec), { paths, servers: undefined, "x-exegesis-controller": "auth" }); } function specWithEmulatorServer(protocol, host) { const paths = {}; Object.entries(apiSpec.paths).forEach(([path, pathObj]) => { const servers = pathObj.servers; if (servers) { pathObj = Object.assign(Object.assign({}, pathObj), { servers: serversWithEmulators(servers) }); } paths[path] = pathObj; }); if (!apiSpec.servers) { throw new Error("No servers defined in API spec."); } return Object.assign(Object.assign({}, apiSpec), { servers: serversWithEmulators(apiSpec.servers), paths }); function serversWithEmulators(servers) { const result = []; for (const server of servers) { result.push({ url: server.url ? server.url.replace("https://", "{EMULATOR}/") : "{EMULATOR}", variables: { EMULATOR: { default: host ? `${protocol}://${host}` : "", description: "The protocol, hostname, and port of Firebase Auth Emulator.", }, }, }); if (server.url) { result.push(server); } } return result; } } async function createApp(defaultProjectId, singleProjectMode = index_1.SingleProjectMode.NO_WARNING, projectStateForId = new Map()) { const app = express(); app.set("json spaces", 2); app.use("/", (req, res, next) => { if (req.headers["access-control-request-private-network"]) { res.setHeader("access-control-allow-private-network", "true"); } next(); }); app.use(cors({ origin: true })); app.delete("*", (req, _, next) => { delete req.headers["content-type"]; next(); }); app.get("/", (req, res) => { return res.json({ authEmulator: { ready: true, docs: "https://firebase.google.com/docs/emulator-suite", apiSpec: API_SPEC_PATH, }, }); }); app.get(API_SPEC_PATH, (req, res) => { res.json(specWithEmulatorServer(req.protocol, req.headers.host)); }); registerLegacyRoutes(app); (0, handlers_1.registerHandlers)(app, (apiKey, tenantId) => getProjectStateById(getProjectIdByApiKey(apiKey), tenantId)); const apiKeyAuthenticator = (ctx, info) => { if (!info.name) { throw new Error("apiKey param/header name is undefined in API spec."); } let key; const req = ctx.req; switch (info.in) { case "header": key = req.get(info.name); break; case "query": { const q = req.query[info.name]; key = typeof q === "string" ? q : undefined; break; } default: throw new Error('apiKey must be defined as in: "query" or "header" in API spec.'); } if (key) { return { type: "success", user: getProjectIdByApiKey(key) }; } else { return undefined; } }; const oauth2Authenticator = (ctx) => { const authorization = ctx.req.headers["authorization"]; if (!authorization || !authorization.toLowerCase().startsWith(AUTH_HEADER_PREFIX)) { return undefined; } const scopes = Object.keys(ctx.api.openApiDoc.components.securitySchemes.Oauth2.flows.authorizationCode.scopes); const token = authorization.substr(AUTH_HEADER_PREFIX.length); if (token.toLowerCase() === "owner") { return { type: "success", user: defaultProjectId, scopes }; } else if (token.startsWith(SERVICE_ACCOUNT_TOKEN_PREFIX)) { emulatorLogger_1.EmulatorLogger.forEmulator(types_1.Emulators.AUTH).log("WARN", `Received service account token ${token}. Assuming that it owns project "${defaultProjectId}".`); return { type: "success", user: defaultProjectId, scopes }; } 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.", [ { message: "Invalid Credentials", domain: "global", reason: "authError", location: "Authorization", locationType: "header", }, ]); }; const apis = await exegesisExpress.middleware(specForRouter(), { controllers: { auth: toExegesisController(operations_1.authOperations, getProjectStateById) }, authenticators: { apiKeyQuery: apiKeyAuthenticator, apiKeyHeader: apiKeyAuthenticator, Oauth2: oauth2Authenticator, }, autoHandleHttpErrors(err) { if (err.type === "entity.parse.failed") { const message = `Invalid JSON payload received. ${err.message}`; err = new errors_2.InvalidArgumentError(message, [ { message, domain: "global", reason: "parseError", }, ]); } if (err instanceof errors_1.ValidationError) { const firstError = err.errors[0]; let details; if (firstError.location) { details = `${firstError.location.path} ${firstError.message}`; } else { details = firstError.message; } err = new errors_2.InvalidArgumentError(`Invalid JSON payload received. ${details}`); } if (err.name === "HttpBadRequestError") { err = new errors_2.BadRequestError(err.message, "unknown"); } throw err; }, defaultMaxBodySize: 1024 * 1024 * 1024, validateDefaultResponses: true, onResponseValidationError({ errors }) { (0, utils_1.logError)(new Error(`An internal error occured when generating response. Details:\n${JSON.stringify(errors)}`)); throw new errors_2.InternalError("An internal error occured when generating response.", "emulator-response-validation"); }, customFormats: { "google-datetime"() { return true; }, "google-fieldmask"() { return true; }, "google-duration"() { return true; }, uint64() { return true; }, uint32() { return true; }, byte() { return true; }, }, plugins: [ { info: { name: "test" }, makeExegesisPlugin() { return { postSecurity(pluginContext) { wrapValidateBody(pluginContext); return Promise.resolve(); }, postController(ctx) { if (ctx.res.statusCode === 401) { const requirements = ctx.api.operationObject.security; if (requirements === null || requirements === void 0 ? void 0 : requirements.some((req) => req.apiKeyQuery || req.apiKeyHeader)) { throw new errors_2.PermissionDeniedError("The request is missing a valid API key."); } else { 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.", [ { message: "Login Required.", domain: "global", reason: "required", location: "Authorization", locationType: "header", }, ]); } } }, }; }, }, ], }); app.use(apis); app.use(() => { throw new errors_2.NotFoundError(); }); app.use(((err, req, res, next) => { let apiError; if (err instanceof errors_2.ApiError) { apiError = err; } else if (!err.status || err.status === 500) { apiError = new errors_2.UnknownError(err.message || "Unknown error", err.name || "unknown"); } else { return res.status(err.status).json(err); } if (apiError.code === 500) { (0, utils_1.logError)(err); } return res.status(apiError.code).json({ error: apiError }); })); return app; function getProjectIdByApiKey(apiKey) { apiKey; return defaultProjectId; } function getProjectStateById(projectId, tenantId) { let agentState = projectStateForId.get(projectId); if (singleProjectMode !== index_1.SingleProjectMode.NO_WARNING && projectId && defaultProjectId !== projectId) { const errorString = `Multiple projectIds are not recommended in single project mode. ` + `Requested project ID ${projectId}, but the emulator is configured for ` + `${defaultProjectId}. To opt-out of single project mode add/set the ` + `\'"singleProjectMode"\' false' property in the firebase.json emulators config.`; emulatorLogger_1.EmulatorLogger.forEmulator(types_1.Emulators.AUTH).log("WARN", errorString); if (singleProjectMode === index_1.SingleProjectMode.ERROR) { throw new errors_2.BadRequestError(errorString); } } if (!agentState) { agentState = new state_1.AgentProjectState(projectId); projectStateForId.set(projectId, agentState); } if (!tenantId) { return agentState; } return agentState.getTenantProject(tenantId); } } exports.createApp = createApp; function registerLegacyRoutes(app) { const relyingPartyPrefix = "/www.googleapis.com/identitytoolkit/v3/relyingparty/"; const v1Prefix = "/identitytoolkit.googleapis.com/v1/"; for (const [oldPath, newPath] of [ ["createAuthUri", "accounts:createAuthUri"], ["deleteAccount", "accounts:delete"], ["emailLinkSignin", "accounts:signInWithEmailLink"], ["getAccountInfo", "accounts:lookup"], ["getOobConfirmationCode", "accounts:sendOobCode"], ["getProjectConfig", "projects"], ["getRecaptchaParam", "recaptchaParams"], ["publicKeys", "publicKeys"], ["resetPassword", "accounts:resetPassword"], ["sendVerificationCode", "accounts:sendVerificationCode"], ["setAccountInfo", "accounts:update"], ["setProjectConfig", "setProjectConfig"], ["signupNewUser", "accounts:signUp"], ["verifyAssertion", "accounts:signInWithIdp"], ["verifyCustomToken", "accounts:signInWithCustomToken"], ["verifyPassword", "accounts:signInWithPassword"], ["verifyPhoneNumber", "accounts:signInWithPhoneNumber"], ]) { app.all(`${relyingPartyPrefix}${oldPath}`, (req, _, next) => { req.url = `${v1Prefix}${newPath}`; next(); }); } app.post(`${relyingPartyPrefix}signOutUser`, () => { throw new errors_2.NotImplementedError(`signOutUser is not implemented in the Auth Emulator.`); }); for (const [oldPath, newMethod, newPath] of [ ["downloadAccount", "GET", "accounts:batchGet"], ["uploadAccount", "POST", "accounts:batchCreate"], ]) { app.post(`${relyingPartyPrefix}${oldPath}`, bodyParser.json(), (req, res, next) => { req.body = convertKeysToCamelCase(req.body || {}); const targetProjectId = req.body.targetProjectId; if (!targetProjectId) { return next(new errors_2.BadRequestError("INSUFFICIENT_PERMISSION")); } delete req.body.targetProjectId; req.method = newMethod; let qs = req.url.split("?", 2)[1] || ""; if (newMethod === "GET") { Object.assign(req.query, req.body); const bodyAsQuery = new url_1.URLSearchParams(req.body).toString(); qs = qs ? `${qs}&${bodyAsQuery}` : bodyAsQuery; delete req.body; delete req.headers["content-type"]; } req.url = `${v1Prefix}projects/${encodeURIComponent(targetProjectId)}/${newPath}?${qs}`; next(); }); } } function toExegesisController(ops, getProjectStateById) { const result = {}; processNested(ops, ""); return new Proxy(result, { get: (obj, prop) => { if (typeof prop !== "string" || prop in obj) { return obj[prop]; } const stub = () => { throw new errors_2.NotImplementedError(`${prop} is not implemented in the Auth Emulator.`); }; return stub; }, }); function processNested(nested, prefix) { Object.entries(nested).forEach(([key, value]) => { if (typeof value === "function") { result[`${prefix}${key}`] = toExegesisOperation(value); } else { processNested(value, `${prefix}${key}.`); if (typeof value._ === "function") { result[`${prefix}${key}`] = toExegesisOperation(value._); } } }); } function toExegesisOperation(operation) { return (ctx) => { var _a, _b, _c, _d, _e, _f, _g, _h; let targetProjectId = ctx.params.path.targetProjectId || ((_a = ctx.requestBody) === null || _a === void 0 ? void 0 : _a.targetProjectId); if (targetProjectId) { if ((_b = ctx.api.operationObject.security) === null || _b === void 0 ? void 0 : _b.some((sec) => sec.Oauth2)) { (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."); } } else { targetProjectId = ctx.user; } let targetTenantId = undefined; if (ctx.params.path.tenantId && ((_d = ctx.requestBody) === null || _d === void 0 ? void 0 : _d.tenantId)) { (0, errors_2.assert)(ctx.params.path.tenantId === ctx.requestBody.tenantId, "TENANT_ID_MISMATCH"); } targetTenantId = ctx.params.path.tenantId || ((_e = ctx.requestBody) === null || _e === void 0 ? void 0 : _e.tenantId); if ((_f = ctx.requestBody) === null || _f === void 0 ? void 0 : _f.idToken) { const idToken = (_g = ctx.requestBody) === null || _g === void 0 ? void 0 : _g.idToken; const decoded = (0, jsonwebtoken_1.decode)(idToken, { complete: true }); if ((decoded === null || decoded === void 0 ? void 0 : decoded.payload.firebase.tenant) && targetTenantId) { (0, errors_2.assert)((decoded === null || decoded === void 0 ? void 0 : decoded.payload.firebase.tenant) === targetTenantId, "TENANT_ID_MISMATCH"); } targetTenantId = targetTenantId || (decoded === null || decoded === void 0 ? void 0 : decoded.payload.firebase.tenant); } if ((_h = ctx.requestBody) === null || _h === void 0 ? void 0 : _h.refreshToken) { const refreshTokenRecord = (0, state_1.decodeRefreshToken)(ctx.requestBody.refreshToken); if (refreshTokenRecord.tenantId && targetTenantId) { (0, errors_2.assert)(refreshTokenRecord.tenantId === targetTenantId, "TENANT_ID_MISMATCH: ((Refresh token tenant ID does not match target tenant ID.))"); } targetTenantId = targetTenantId || refreshTokenRecord.tenantId; } return operation(getProjectStateById(targetProjectId, targetTenantId), ctx.requestBody, ctx); }; } } function wrapValidateBody(pluginContext) { const op = pluginContext._operation; if (op.validateBody && !op._authEmulatorValidateBodyWrapped) { const validateBody = op.validateBody.bind(op); op.validateBody = (body) => { return validateAndFixRestMappingRequestBody(validateBody, body); }; op._authEmulatorValidateBodyWrapped = true; } } function validateAndFixRestMappingRequestBody(validate, body) { var _a; body = convertKeysToCamelCase(body); let result; let keepFixing = false; const fixedPaths = new Set(); do { result = validate(body); if (!result.errors) return result; keepFixing = false; for (const error of result.errors) { const path = (_a = error.location) === null || _a === void 0 ? void 0 : _a.path; const ajvError = error.ajvError; if (!path || fixedPaths.has(path) || !ajvError) { continue; } const dataPath = jsonPointerToPath(path); const value = _.get(body, dataPath); if (ajvError.keyword === "type" && ajvError.params.type === "string") { if (typeof value === "number") { _.set(body, dataPath, value.toString()); keepFixing = true; } } else if (ajvError.keyword === "enum") { const params = ajvError.params; const enumValue = params.allowedValues[value]; if (enumValue) { _.set(body, dataPath, enumValue); keepFixing = true; } } } } while (keepFixing); return result; } function convertKeysToCamelCase(body) { if (body == null || typeof body !== "object") return body; if (Array.isArray(body)) { return body.map(convertKeysToCamelCase); } const result = Object.create(null); for (const key of Object.keys(body)) { result[(0, lodash_1.camelCase)(key)] = convertKeysToCamelCase(body[key]); } return result; } function jsonPointerToPath(pointer) { const path = pointer.split("/").map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")); if (path[0] === "#" || path[0] === "") { path.shift(); } return path; }