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);
}
}