export async function createApp()

in src/emulator/auth/server.ts [117:363]


export async function createApp(
  defaultProjectId: string,
  projectStateForId = new Map<string, AgentProjectState>()
): Promise<express.Express> {
  const app = express();
  app.set("json spaces", 2);
  // Enable CORS for all APIs, all origins (reflected), and all headers (reflected).
  // This is similar to production behavior. Safe since all APIs are cookieless.
  app.use(cors({ origin: true }));

  // Workaround for clients (e.g. Node.js Admin SDK) that send request bodies
  // with HTTP DELETE requests. Such requests are tolerated by production, but
  // exegesis will reject them without the following hack.
  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);
  registerHandlers(app, (apiKey, tenantId) =>
    getProjectStateById(getProjectIdByApiKey(apiKey), tenantId)
  );

  const apiKeyAuthenticator: PromiseAuthenticator = (ctx, info) => {
    if (info.in !== "query") {
      throw new Error('apiKey must be defined as in: "query" in API spec.');
    }
    if (!info.name) {
      throw new Error("apiKey param name is undefined in API spec.");
    }
    const key = (ctx.req as express.Request).query[info.name];
    if (typeof key === "string" && key.length > 0) {
      return { type: "success", user: getProjectIdByApiKey(key) };
    } else {
      return undefined;
    }
  };

  const oauth2Authenticator: PromiseAuthenticator = (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") {
      // We treat "owner" as a valid account token for the default projectId.
      return { type: "success", user: defaultProjectId, scopes };
    } else if (token.startsWith(SERVICE_ACCOUNT_TOKEN_PREFIX) /* case sensitive */) {
      // We have received a production service account token. Since the token is
      // opaque and we cannot infer the projectId without contacting prod, we
      // will also assume that the token belongs to the default projectId.
      EmulatorLogger.forEmulator(Emulators.AUTH).log(
        "WARN",
        `Received service account token ${token}. Assuming that it owns project "${defaultProjectId}".`
      );
      return { type: "success", user: defaultProjectId, scopes };
    }
    throw new 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(authOperations, getProjectStateById) },
    authenticators: {
      apiKey: apiKeyAuthenticator,
      Oauth2: oauth2Authenticator,
    },
    autoHandleHttpErrors(err) {
      // JSON parsing error thrown by body-parser.
      if ((err as { type?: string }).type === "entity.parse.failed") {
        const message = `Invalid JSON payload received. ${err.message}`;
        err = new InvalidArgumentError(message, [
          {
            message,
            domain: "global",
            reason: "parseError",
          },
        ]);
      }
      if (err instanceof ValidationError) {
        // TODO: Shall we expose more than the first error?
        const firstError = err.errors[0];
        let details;
        if (firstError.location) {
          // Add data path into error message, so it is actionable.
          details = `${firstError.location.path} ${firstError.message}`;
        } else {
          details = firstError.message;
        }
        err = new InvalidArgumentError(`Invalid JSON payload received. ${details}`);
      }
      if (err.name === "HttpBadRequestError") {
        err = new BadRequestError(err.message, "unknown");
      }
      // Let errors propagate to our universal error handler below.
      throw err;
    },
    defaultMaxBodySize: 1024 * 1024 * 1024, // 1GB instead of the default 10k.
    validateDefaultResponses: true,
    onResponseValidationError({ errors }) {
      logError(
        new Error(
          `An internal error occured when generating response. Details:\n${JSON.stringify(errors)}`
        )
      );
      throw new InternalError(
        "An internal error occured when generating response.",
        "emulator-response-validation"
      );
    },
    customFormats: {
      "google-datetime"() {
        // TODO
        return true;
      },
      "google-fieldmask"() {
        // TODO
        return true;
      },
      "google-duration"() {
        // TODO
        return true;
      },
      uint64() {
        // TODO
        return true;
      },
      uint32() {
        // TODO
        return true;
      },
      byte() {
        // Disable the "byte" format validation to allow stuffing arbitary
        // strings in passwordHash etc. Needed because the emulator generates
        // non-base64 hash strings like "fakeHash:salt=foo:password=bar".
        return true;
      },
    },
    plugins: [
      {
        info: { name: "test" },
        makeExegesisPlugin() {
          return {
            postSecurity(pluginContext: ExegesisPluginContext): Promise<void> {
              wrapValidateBody(pluginContext);
              return Promise.resolve();
            },
            postController(ctx: ExegesisContext) {
              if (ctx.res.statusCode === 401) {
                // Normalize unauthenticated responses to match production.
                const requirements = (ctx.api.operationObject as OperationObject).security;
                if (requirements?.some((req) => req.apiKey)) {
                  throw new PermissionDeniedError("The request is missing a valid API key.");
                } else {
                  throw new 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);

  // Last catch-all handler. Serves 404. Must be after all routes.
  app.use(() => {
    throw new NotFoundError();
  });

  // The function below must have 4 args in order for Express to consider it as
  // an error handler instead of a normal middleware. DO NOT remove unused args!
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  app.use(((err, req, res, next) => {
    let apiError;
    if (err instanceof ApiError) {
      apiError = err;
    } else if (!err.status || err.status === 500) {
      apiError = new UnknownError(err.message || "Unknown error", err.name || "unknown");
    } else {
      // This is a non-500 error following the http error convention, should probably just expose it.
      // For example, this may be a 413 Entity Too Large from body-parser.
      return res.status(err.status).json(err);
    }
    if (apiError.code === 500) {
      logError(err);
    }
    return res.status(apiError.code).json({ error: apiError });
  }) as express.ErrorRequestHandler);
  return app;

  function getProjectIdByApiKey(apiKey: string): string {
    /* unused */ apiKey;
    // We treat any non-empty string as a valid key for the default projectId.
    return defaultProjectId;
  }

  function getProjectStateById(projectId: string, tenantId?: string): ProjectState {
    let agentState = projectStateForId.get(projectId);
    if (!agentState) {
      agentState = new AgentProjectState(projectId);
      projectStateForId.set(projectId, agentState);
    }
    if (!tenantId) {
      return agentState;
    }

    return agentState.getTenantProject(tenantId);
  }
}