mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-15 20:57:56 +08:00
separate backend base url from frontend (#1819)
* separate backend base url from frontend This is needed for auth, and to support a different location for the backend api server, than the frontend. part of chanzuckerberg/cellxgene#1778 new server config parameters: app__api_base_url, app__web_base_url Also changed api_base_url in the oauth config section to "oauth_api_base_url" to be less confusing with the app's api_base_url Other minor changes: changed how the jwt decode options are handled. Previously they needed to be set in a test case, and there was some extra logic to handle that. Now they are handled through comfig parameters, which makes it more general. Also, add a feature to set the CORS support credentials, which seems to be necessary for the backend/frontend separation, at least when run locally. This part is sort of experimental, and may be removed or changed later.
This commit is contained in:
@@ -2,6 +2,9 @@ import datetime
|
||||
import logging
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from flask import Flask, redirect, current_app, make_response, render_template, abort, Blueprint, request, \
|
||||
send_from_directory
|
||||
@@ -84,10 +87,12 @@ def dataset_index(url_dataroot=None, dataset=None):
|
||||
cache_manager = current_app.matrix_data_cache_manager
|
||||
with cache_manager.data_adaptor(url_dataroot, location, app_config) as data_adaptor:
|
||||
data_adaptor.set_uri_path(f"{url_dataroot}/{dataset}")
|
||||
dataset_title = app_config.get_title(data_adaptor)
|
||||
return render_template(
|
||||
"index.html", datasetTitle=dataset_title, SCRIPTS=scripts, INLINE_SCRIPTS=inline_scripts
|
||||
)
|
||||
args = {
|
||||
"SCRIPTS" : scripts,
|
||||
"INLINE_SCRIPTS" : inline_scripts
|
||||
}
|
||||
return render_template("index.html", **args)
|
||||
|
||||
except DatasetAccessError as e:
|
||||
return common_rest.abort_and_log(
|
||||
e.status_code, f"Invalid dataset {dataset}: {e.message}", loglevel=logging.INFO, include_exc_info=True
|
||||
@@ -179,9 +184,9 @@ def dataroot_test_index():
|
||||
data += f"<p>Logged in as {auth.get_user_id()} / {auth.get_user_name()} / {auth.get_user_email()}</p>"
|
||||
if auth.requires_client_login():
|
||||
if server_config.auth.is_user_authenticated():
|
||||
data += "<p><a href='/logout'>Logout</a></p>"
|
||||
data += f"<p><a href='{auth.get_logout_url(None)}'>Logout</a></p>"
|
||||
else:
|
||||
data += "<p><a href='/login'>Login</a></p>"
|
||||
data += f"<p><a href='{auth.get_login_url(None)}'>Login</a></p>"
|
||||
|
||||
datasets = []
|
||||
for dataroot_dict in server_config.multi_dataset__dataroot.values():
|
||||
@@ -329,6 +334,28 @@ def get_api_resources(bp_api, url_dataroot=None):
|
||||
return api
|
||||
|
||||
|
||||
def handle_api_base_url(app, app_config):
|
||||
"""If an api_base_url is provided, then an inline script is generated to
|
||||
handle the new API prefix"""
|
||||
api_base_url = app_config.server_config.get_api_base_url()
|
||||
if not api_base_url:
|
||||
return
|
||||
|
||||
if api_base_url.endswith("/"):
|
||||
api_base_url = api_base_url[:-1]
|
||||
|
||||
sha256 = hashlib.sha256(api_base_url.encode()).hexdigest()
|
||||
script_name = f"api_base_url-{sha256}.js"
|
||||
script_path = os.path.join(app.root_path, "../common/web/templates", script_name)
|
||||
with open(script_path, "w") as fout:
|
||||
fout.write("window.CELLXGENE.API.prefix = `" + api_base_url + "${location.pathname}api/`;\n")
|
||||
|
||||
dataset_configs = [app_config.default_dataset_config] + list(app_config.dataroot_config.values())
|
||||
for dataset_config in dataset_configs:
|
||||
inline_scripts = dataset_config.app__inline_scripts
|
||||
inline_scripts.append(script_name)
|
||||
|
||||
|
||||
class Server:
|
||||
@staticmethod
|
||||
def _before_adding_routes(app, app_config):
|
||||
@@ -337,6 +364,7 @@ class Server:
|
||||
|
||||
def __init__(self, app_config):
|
||||
self.app = Flask(__name__, static_folder=None)
|
||||
handle_api_base_url(self.app, app_config)
|
||||
self._before_adding_routes(self.app, app_config)
|
||||
self.app.json_encoder = Float32JSONEncoder
|
||||
server_config = app_config.server_config
|
||||
@@ -353,6 +381,12 @@ class Server:
|
||||
self.app.register_blueprint(webbp)
|
||||
|
||||
api_version = "/api/v0.2"
|
||||
api_base_url = server_config.get_api_base_url()
|
||||
api_path = "/"
|
||||
if api_base_url:
|
||||
parse = urlparse(api_base_url)
|
||||
api_path = parse.path
|
||||
|
||||
if app_config.is_multi_dataset():
|
||||
# NOTE: These routes only allow the dataset to be in the directory
|
||||
# of the dataroot, and not a subdirectory. We may want to change
|
||||
@@ -360,7 +394,8 @@ class Server:
|
||||
for dataroot_dict in server_config.multi_dataset__dataroot.values():
|
||||
url_dataroot = dataroot_dict["base_url"]
|
||||
bp_api = Blueprint(
|
||||
f"api_dataset_{url_dataroot}", __name__, url_prefix=f"/{url_dataroot}/<dataset>" + api_version
|
||||
f"api_dataset_{url_dataroot}", __name__,
|
||||
url_prefix=f"{api_path}/{url_dataroot}/<dataset>" + api_version
|
||||
)
|
||||
resources = get_api_resources(bp_api, url_dataroot)
|
||||
self.app.register_blueprint(resources.blueprint)
|
||||
@@ -378,7 +413,7 @@ class Server:
|
||||
)
|
||||
|
||||
else:
|
||||
bp_api = Blueprint("api", __name__, url_prefix=api_version)
|
||||
bp_api = Blueprint("api", __name__, url_prefix=f"{api_path}{api_version}")
|
||||
resources = get_api_resources(bp_api)
|
||||
self.app.register_blueprint(resources.blueprint)
|
||||
self.app.add_url_rule(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from flask import session, request, redirect, current_app, after_this_request, has_request_context, g
|
||||
from server.auth.auth import AuthTypeClientBase, AuthTypeFactory
|
||||
from server.common.errors import AuthenticationError, ConfigurationError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlencode, urlparse
|
||||
import json
|
||||
import requests
|
||||
import base64
|
||||
@@ -45,13 +45,20 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
||||
if missingimport:
|
||||
raise ConfigurationError(f"oauth requires these modules: {', '.join(missingimport)}")
|
||||
self.algorithms = ["RS256"]
|
||||
self.api_base_url = server_config.authentication__params_oauth__api_base_url
|
||||
self.oauth_api_base_url = server_config.authentication__params_oauth__oauth_api_base_url
|
||||
self.client_id = server_config.authentication__params_oauth__client_id
|
||||
self.client_secret = server_config.authentication__params_oauth__client_secret
|
||||
self.callback_base_url = server_config.authentication__params_oauth__callback_base_url
|
||||
self.session_cookie = server_config.authentication__params_oauth__session_cookie
|
||||
self.cookie_params = server_config.authentication__params_oauth__cookie
|
||||
self.jwt_decode_options = server_config.authentication__params_oauth__jwt_decode_options
|
||||
|
||||
self._validate_cookie_params()
|
||||
self._validate_jwt_decode_options()
|
||||
|
||||
self.api_base_url = server_config.get_api_base_url()
|
||||
self.web_base_url = server_config.get_web_base_url()
|
||||
if self.api_base_url is None:
|
||||
raise ConfigurationError("oauth requires the app__api_base_url to be set")
|
||||
|
||||
# set the audience
|
||||
self.audience = self.client_id
|
||||
@@ -60,11 +67,13 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
||||
# The JSON Web Key Set (JWKS) is a set of keys which contains the public keys used to verify
|
||||
# any JSON Web Token (JWT) issued by the authorization server and signed using the RS256
|
||||
try:
|
||||
jwksloc = f"{self.api_base_url}/.well-known/jwks.json"
|
||||
jwksloc = f"{self.oauth_api_base_url}/.well-known/jwks.json"
|
||||
jwksurl = requests.get(jwksloc)
|
||||
self.jwks = jwksurl.json()
|
||||
except Exception:
|
||||
raise ConfigurationError(f"error in oauth, api_url_base: {self.api_base_url}, cannot access {jwksloc}")
|
||||
raise ConfigurationError(
|
||||
f"error in oauth, api_url_base: {self.oauth_api_base_url}, cannot access {jwksloc}"
|
||||
)
|
||||
|
||||
def _validate_cookie_params(self):
|
||||
"""check the cookie_params, and raise a ConfigurationError if there is something wrong"""
|
||||
@@ -81,6 +90,20 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
||||
if "key" not in keys:
|
||||
raise ConfigurationError("must have a key (name) in the cookie params")
|
||||
|
||||
def _validate_jwt_decode_options(self):
|
||||
"""check the jwt_decode_options, and raise a ConfigurationError if there is something wrong"""
|
||||
if self.jwt_decode_options is None:
|
||||
self.jwt_decode_options = {}
|
||||
return
|
||||
|
||||
valid_keys = {
|
||||
"verify_signature", "verify_aud", "verify_iat", "verify_exp", "verify_nbf", "verify_iss",
|
||||
"verify_sub", "verify_jti", "verify_at_hash", "leeway"}
|
||||
keys = set(self.jwt_decode_options.keys())
|
||||
unknown = keys - valid_keys
|
||||
if unknown:
|
||||
raise ConfigurationError(f"unexpected key in jwt_decode_options: {', '.join(unknown)}")
|
||||
|
||||
def is_valid_authentication_type(self):
|
||||
return True
|
||||
|
||||
@@ -88,27 +111,22 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
||||
return True
|
||||
|
||||
def add_url_rules(self, app):
|
||||
app.add_url_rule("/login", "login", self.login, methods=["GET"])
|
||||
app.add_url_rule("/logout", "logout", self.logout, methods=["GET"])
|
||||
app.add_url_rule("/oauth2/callback", "callback", self.callback, methods=["GET"])
|
||||
parse = urlparse(self.api_base_url)
|
||||
app.add_url_rule(f"{parse.path}/login", "login", self.login, methods=["GET"])
|
||||
app.add_url_rule(f"{parse.path}/logout", "logout", self.logout, methods=["GET"])
|
||||
app.add_url_rule(f"{parse.path}/oauth2/callback", "callback", self.callback, methods=["GET"])
|
||||
|
||||
def complete_setup(self, flask_app):
|
||||
self.oauth = OAuth(flask_app)
|
||||
if self.callback_base_url is None:
|
||||
# In this case, assume the server is running on the same host as the client,
|
||||
# and the oauth provider has been configured
|
||||
# with a callback that understands a localhost callback (e.g. A http://localhost:5005).
|
||||
server_config = flask_app.app_config.server_config
|
||||
self.callback_base_url = f"http://{server_config.app__host}:{server_config.app__port}"
|
||||
|
||||
self.client = self.oauth.register(
|
||||
"auth0",
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
api_base_url=self.api_base_url,
|
||||
refresh_token_url=f"{self.api_base_url}/oauth/token",
|
||||
access_token_url=f"{self.api_base_url}/oauth/token",
|
||||
authorize_url=f"{self.api_base_url}/authorize",
|
||||
api_base_url=self.oauth_api_base_url,
|
||||
refresh_token_url=f"{self.oauth_api_base_url}/oauth/token",
|
||||
access_token_url=f"{self.oauth_api_base_url}/oauth/token",
|
||||
authorize_url=f"{self.oauth_api_base_url}/authorize",
|
||||
client_kwargs={"scope": "openid profile email offline_access"},
|
||||
)
|
||||
|
||||
@@ -138,9 +156,9 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
||||
response.cache_control.update(dict(public=True, max_age=0, no_store=True, no_cache=True, must_revalidate=True))
|
||||
|
||||
def login(self):
|
||||
callbackurl = f"{self.callback_base_url}/oauth2/callback"
|
||||
callbackurl = f"{self.api_base_url}/oauth2/callback"
|
||||
return_path = request.args.get("dataset", "")
|
||||
return_to = f"{self.callback_base_url}/{return_path}"
|
||||
return_to = f"{self.web_base_url}/{return_path}/"
|
||||
# save the return path in the session cookie, accessed in the callback function
|
||||
session["oauth_callback_redirect"] = return_to
|
||||
response = self.client.authorize_redirect(redirect_uri=callbackurl)
|
||||
@@ -149,7 +167,7 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
||||
|
||||
def logout(self):
|
||||
self.remove_tokens()
|
||||
params = {"returnTo": self.callback_base_url, "client_id": self.client_id}
|
||||
params = {"returnTo": self.web_base_url, "client_id": self.client_id}
|
||||
response = redirect(self.client.api_base_url + "/v2/logout?" + urlencode(params))
|
||||
self.update_response(response)
|
||||
return response
|
||||
@@ -228,14 +246,14 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
||||
|
||||
def get_login_url(self, data_adaptor):
|
||||
"""Return the url for the login route"""
|
||||
if current_app.app_config.is_multi_dataset():
|
||||
return f"/login?dataset={data_adaptor.uri_path}/"
|
||||
if data_adaptor and current_app.app_config.is_multi_dataset():
|
||||
return f"{self.api_base_url}/login?dataset={data_adaptor.uri_path}/"
|
||||
else:
|
||||
return "/login"
|
||||
return f"{self.api_base_url}/login"
|
||||
|
||||
def get_logout_url(self, data_adaptor):
|
||||
"""Return the url for the logout route"""
|
||||
return "/logout"
|
||||
return f"{self.api_base_url}/logout"
|
||||
|
||||
def check_jwt_payload(self, id_token):
|
||||
try:
|
||||
@@ -254,18 +272,14 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
||||
"e": key.get("e"),
|
||||
}
|
||||
if rsa_key:
|
||||
options = {}
|
||||
if not rsa_key["n"] or not rsa_key["e"]:
|
||||
# this is a mock auth server, do not validate
|
||||
options = {"verify_signature": False, "verify_iss": False}
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
id_token,
|
||||
rsa_key,
|
||||
algorithms=self.algorithms,
|
||||
audience=self.audience,
|
||||
issuer=self.api_base_url + "/",
|
||||
options=options,
|
||||
issuer=self.oauth_api_base_url + "/",
|
||||
options=self.jwt_decode_options,
|
||||
)
|
||||
return payload
|
||||
|
||||
@@ -321,7 +335,7 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
||||
"client_secret": self.client_secret,
|
||||
}
|
||||
headers = {"content-type": "application/x-www-form-urlencoded"}
|
||||
request = requests.post(f"{self.api_base_url}/oauth/token", urlencode(params), headers=headers)
|
||||
request = requests.post(f"{self.oauth_api_base_url}/oauth/token", urlencode(params), headers=headers)
|
||||
if request.status_code != 200:
|
||||
# unable to refresh the token, log the user out
|
||||
self.remove_tokens()
|
||||
|
||||
@@ -296,7 +296,7 @@ class CliLaunchServer(Server):
|
||||
"application/octet-stream",
|
||||
]
|
||||
Compress(app)
|
||||
if app_config.server_config.app__debug:
|
||||
if app_config.server_config.app__cors_supports_credentials or app_config.server_config.app__debug:
|
||||
CORS(app, supports_credentials=True)
|
||||
|
||||
|
||||
|
||||
@@ -417,6 +417,7 @@ class ServerConfig(BaseConfig):
|
||||
dictval_cases = [
|
||||
("app", "csp_directives"),
|
||||
("authentication", "params_oauth", "cookie"),
|
||||
("authentication", "params_oauth", "jwt_decode_options"),
|
||||
("adaptor", "cxg_adaptor", "tiledb_ctx"),
|
||||
("multi_dataset", "dataroot"),
|
||||
]
|
||||
@@ -434,13 +435,18 @@ class ServerConfig(BaseConfig):
|
||||
self.app__generate_cache_control_headers = dc["app"]["generate_cache_control_headers"]
|
||||
self.app__server_timing_headers = dc["app"]["server_timing_headers"]
|
||||
self.app__csp_directives = dc["app"]["csp_directives"]
|
||||
self.app__cors_supports_credentials = dc["app"]["cors_supports_credentials"]
|
||||
self.app__api_base_url = dc["app"]["api_base_url"]
|
||||
self.app__web_base_url = dc["app"]["web_base_url"]
|
||||
|
||||
self.authentication__type = dc["authentication"]["type"]
|
||||
self.authentication__params_oauth__api_base_url = dc["authentication"]["params_oauth"]["api_base_url"]
|
||||
self.authentication__params_oauth__oauth_api_base_url = dc["authentication"]["params_oauth"][
|
||||
"oauth_api_base_url"
|
||||
]
|
||||
self.authentication__params_oauth__client_id = dc["authentication"]["params_oauth"]["client_id"]
|
||||
self.authentication__params_oauth__client_secret = dc["authentication"]["params_oauth"]["client_secret"]
|
||||
self.authentication__params_oauth__callback_base_url = \
|
||||
dc["authentication"]["params_oauth"]["callback_base_url"]
|
||||
self.authentication__params_oauth__jwt_decode_options = dc["authentication"]["params_oauth"][
|
||||
"jwt_decode_options"]
|
||||
self.authentication__params_oauth__session_cookie = dc["authentication"]["params_oauth"]["session_cookie"]
|
||||
self.authentication__params_oauth__cookie = dc["authentication"]["params_oauth"]["cookie"]
|
||||
|
||||
@@ -500,7 +506,10 @@ class ServerConfig(BaseConfig):
|
||||
self.check_attr("app__flask_secret_key", (type(None), str))
|
||||
self.check_attr("app__generate_cache_control_headers", bool)
|
||||
self.check_attr("app__server_timing_headers", bool)
|
||||
self.check_attr("app__cors_supports_credentials", bool)
|
||||
self.check_attr("app__csp_directives", (type(None), dict))
|
||||
self.check_attr("app__api_base_url", (type(None), str))
|
||||
self.check_attr("app__web_base_url", (type(None), str))
|
||||
|
||||
if self.app__port:
|
||||
try:
|
||||
@@ -549,15 +558,18 @@ class ServerConfig(BaseConfig):
|
||||
elif not isinstance(v, str):
|
||||
raise ConfigurationError("CSP directive value must be a string or list of strings.")
|
||||
|
||||
if self.app__web_base_url is None:
|
||||
self.app__web_base_url = self.app__api_base_url
|
||||
|
||||
def handle_authentication(self, context):
|
||||
self.check_attr("authentication__type", (type(None), str))
|
||||
|
||||
# oauth
|
||||
ptypes = str if self.authentication__type == "oauth" else (type(None), str)
|
||||
self.check_attr("authentication__params_oauth__api_base_url", ptypes)
|
||||
self.check_attr("authentication__params_oauth__oauth_api_base_url", ptypes)
|
||||
self.check_attr("authentication__params_oauth__client_id", ptypes)
|
||||
self.check_attr("authentication__params_oauth__client_secret", ptypes)
|
||||
self.check_attr("authentication__params_oauth__callback_base_url", (type(None), str))
|
||||
self.check_attr("authentication__params_oauth__jwt_decode_options", (type(None), dict))
|
||||
self.check_attr("authentication__params_oauth__session_cookie", bool)
|
||||
|
||||
if self.authentication__params_oauth__session_cookie:
|
||||
@@ -743,6 +755,18 @@ class ServerConfig(BaseConfig):
|
||||
return False
|
||||
return value > limit_value
|
||||
|
||||
def get_api_base_url(self):
|
||||
if self.app__api_base_url == "local":
|
||||
return f"http://{self.app__host}:{self.app__port}"
|
||||
return self.app__api_base_url
|
||||
|
||||
def get_web_base_url(self):
|
||||
if self.app__web_base_url == "local":
|
||||
return f"http://{self.app__host}:{self.app__port}"
|
||||
if self.app__web_base_url is None:
|
||||
return self.get_api_base_url()
|
||||
return self.app__web_base_url
|
||||
|
||||
|
||||
class DatasetConfig(BaseConfig):
|
||||
"""Manages the config attribute associated with a dataset."""
|
||||
@@ -769,7 +793,7 @@ class DatasetConfig(BaseConfig):
|
||||
self.user_annotations__ontology__obo_location = dc["user_annotations"]["ontology"]["obo_location"]
|
||||
self.user_annotations__hosted_tiledb_array__db_uri = dc["user_annotations"]["hosted_tiledb_array"]["db_uri"]
|
||||
self.user_annotations__hosted_tiledb_array__hosted_file_directory = \
|
||||
dc["user_annotations"]["hosted_tiledb_array"]["hosted_file_directory"] # noqa E501
|
||||
dc["user_annotations"][ "hosted_tiledb_array" ][ "hosted_file_directory" ] # noqa E501
|
||||
|
||||
self.embeddings__names = dc["embeddings"]["names"]
|
||||
self.embeddings__enable_reembedding = dc["embeddings"]["enable_reembedding"]
|
||||
|
||||
@@ -14,6 +14,25 @@ server:
|
||||
server_timing_headers: false
|
||||
csp_directives: null
|
||||
|
||||
# CORS: Cross Origin Resource Sharing. If true, this allow users to make
|
||||
# authenticated requests. This allows cookies and credentials to be submitted
|
||||
# across domains
|
||||
cors_supports_credentials: false
|
||||
|
||||
# By default, cellxgene will serve api requests from the same base url as the webpage.
|
||||
# In general api_base_url and web_base_url will not need to be set.
|
||||
# There are two reasons to set these parameters:
|
||||
# 1. Oauth authentication is used; the oauth server will redirect back to the api_base_url after login,
|
||||
# which then redirects back to the web_base_url. If the web_base_url is not set, it will default to
|
||||
# the api_base_url. If oauth authentication is used, the api_base_url must be set.
|
||||
# For a local test (where the server runs on "http://localhost:<port>"), then the api_base_url may be
|
||||
# set to the string "local".
|
||||
# 2. The cellxgene deploymnent is in an environment where the webpage and api have
|
||||
# different base urls. In this case both api_base_url and web_base_url must be set.
|
||||
# It is up to the server admin to ensure that the networking is setup correctly for this environment.
|
||||
api_base_url: null
|
||||
web_base_url: null
|
||||
|
||||
authentication:
|
||||
# The authentication types may be "none", "session", "oauth"
|
||||
# none: No authentication support, features like user_annotations must not be enabled.
|
||||
@@ -22,16 +41,17 @@ server:
|
||||
type: session
|
||||
|
||||
params_oauth:
|
||||
# url to the auth server
|
||||
api_base_url: null
|
||||
# url to the oauth server
|
||||
oauth_api_base_url: null
|
||||
# client_id of this app
|
||||
client_id: null
|
||||
# the client_secret known to the auth server and this app
|
||||
client_secret: null
|
||||
# cellxgene server location;
|
||||
# the browser will be redirected to locations relative to this location during login and logout.
|
||||
# A value of None, indicates the client and server are on the localhost. http://localhost:<port> will be used.
|
||||
callback_base_url: null
|
||||
# jwt_decode_options, to specify non default decode options define
|
||||
# jwt_decode_options to be a dictionary with key/values described by
|
||||
# the options parameter of the jose.jwt.decode function:
|
||||
# (https://python-jose.readthedocs.io/en/latest/jwt/api.html)
|
||||
jwt_decode_options: null
|
||||
|
||||
# if true, the jwt containing the id_token is stored in a session cookie
|
||||
session_cookie: true
|
||||
|
||||
@@ -131,16 +131,24 @@ def start_test_server(command_line_args=[], app_config=None):
|
||||
|
||||
where the server can be accessed within the context, and is terminated when
|
||||
the context is exited.
|
||||
The port is automatically set using find_available_port.
|
||||
The port is automatically set using find_available_port, unless passed in as a command line arg.
|
||||
The verbose flag is automatically set to True.
|
||||
If an app_config is provided, then this function writes a temporary
|
||||
yaml config file, which this server will read and parse.
|
||||
"""
|
||||
|
||||
start = random.randint(DEFAULT_SERVER_PORT, 2 ** 16 - 1)
|
||||
port = int(os.environ.get("CXG_SERVER_PORT", start))
|
||||
port = find_available_port("localhost", port)
|
||||
command = ["cellxgene", "--no-upgrade-check", "launch", "--verbose", "--port=%d" % port] + command_line_args
|
||||
command = ["cellxgene", "--no-upgrade-check", "launch", "--verbose"]
|
||||
if "-p" in command_line_args:
|
||||
port = int(command_line_args[command_line_args.index("-p") + 1])
|
||||
elif "--port" in command_line_args:
|
||||
port = int(command_line_args[command_line_args.index("--port") + 1])
|
||||
else:
|
||||
start = random.randint(DEFAULT_SERVER_PORT, 2 ** 16 - 1)
|
||||
port = int(os.environ.get("CXG_SERVER_PORT", start))
|
||||
port = find_available_port("localhost", port)
|
||||
command += ["--port=%d" % port]
|
||||
|
||||
command += command_line_args
|
||||
|
||||
tempdir = None
|
||||
if app_config:
|
||||
|
||||
@@ -19,7 +19,7 @@ from server.test import FIXTURES_ROOT, test_server
|
||||
# oauth server.
|
||||
|
||||
# number of seconds that the oauth token is valid
|
||||
TOKEN_EXPIRES = 5
|
||||
TOKEN_EXPIRES = 2
|
||||
|
||||
# Create a mocked out oauth token, which servers all the endpoints needed by the oauth type.
|
||||
mock_oauth_app = Flask("mock_oauth_app")
|
||||
@@ -34,17 +34,19 @@ def authorize():
|
||||
|
||||
@mock_oauth_app.route("/oauth/token", methods=["POST"])
|
||||
def token():
|
||||
now = time.time()
|
||||
expires_at = now + TOKEN_EXPIRES
|
||||
headers = dict(alg="RS256", kid="fake_kid")
|
||||
payload = dict(name="fake_user", sub="fake_id", email="fake_user@email.com", email_verified=True)
|
||||
payload = dict(name="fake_user", sub="fake_id", email="fake_user@email.com", email_verified=True, exp=expires_at)
|
||||
jwt = jose.jwt.encode(claims=payload, key="mysecret", algorithm="HS256", headers=headers)
|
||||
r = {
|
||||
"access_token": f"access-{time.time()}",
|
||||
"access_token": f"access-{now}",
|
||||
"id_token": jwt,
|
||||
"refresh_token": f"random-{time.time()}",
|
||||
"refresh_token": f"random-{now}",
|
||||
"scope": "openid profile email",
|
||||
"expires_in": TOKEN_EXPIRES,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": time.time() + TOKEN_EXPIRES,
|
||||
"expires_at": expires_at
|
||||
}
|
||||
return make_response(jsonify(r))
|
||||
|
||||
@@ -81,6 +83,19 @@ class AuthTest(unittest.TestCase):
|
||||
|
||||
def auth_flow(self, app_config, cookie_key=None):
|
||||
|
||||
app_config.update_server_config(
|
||||
app__api_base_url="local",
|
||||
authentication__type="oauth",
|
||||
authentication__params_oauth__oauth_api_base_url=f"http://localhost:{PORT}",
|
||||
authentication__params_oauth__client_id="mock_client_id",
|
||||
authentication__params_oauth__client_secret="mock_client_secret",
|
||||
authentication__params_oauth__jwt_decode_options={
|
||||
"verify_signature": False, "verify_iss": False
|
||||
})
|
||||
|
||||
app_config.update_server_config(multi_dataset__dataroot=self.dataset_dataroot)
|
||||
app_config.complete_config()
|
||||
|
||||
with test_server(app_config=app_config) as server:
|
||||
session = requests.Session()
|
||||
|
||||
@@ -96,10 +111,10 @@ class AuthTest(unittest.TestCase):
|
||||
login_uri = config["config"]["authentication"]["login"]
|
||||
logout_uri = config["config"]["authentication"]["logout"]
|
||||
|
||||
self.assertEqual(login_uri, "/login?dataset=d/pbmc3k.cxg/")
|
||||
self.assertEqual(logout_uri, "/logout")
|
||||
self.assertEqual(login_uri, f"{server}/login?dataset=d/pbmc3k.cxg/")
|
||||
self.assertEqual(logout_uri, f"{server}/logout")
|
||||
|
||||
r = session.get(f"{server}/{login_uri}")
|
||||
r = session.get(login_uri)
|
||||
# check that the login redirect worked
|
||||
self.assertEqual(r.history[0].status_code, 302)
|
||||
self.assertEqual(r.url, f"{server}/d/pbmc3k.cxg/")
|
||||
@@ -113,13 +128,13 @@ class AuthTest(unittest.TestCase):
|
||||
cookie = session.cookies.get(cookie_key)
|
||||
token = json.loads(base64.b64decode(cookie))
|
||||
access_token_before = token.get("access_token")
|
||||
expires_at_before = token.get("expires_at")
|
||||
id_token_before = token.get("id_token")
|
||||
|
||||
# let the token expire
|
||||
time.sleep(TOKEN_EXPIRES + 1)
|
||||
|
||||
# check that refresh works
|
||||
session.get(f"{server}/{login_uri}")
|
||||
session.get(login_uri)
|
||||
userinfo = session.get(f"{server}/d/pbmc3k.cxg/api/v0.2/userinfo").json()
|
||||
self.assertTrue(userinfo["userinfo"]["is_authenticated"])
|
||||
self.assertEqual(userinfo["userinfo"]["username"], "fake_user")
|
||||
@@ -127,12 +142,12 @@ class AuthTest(unittest.TestCase):
|
||||
cookie = session.cookies.get(cookie_key)
|
||||
token = json.loads(base64.b64decode(cookie))
|
||||
access_token_after = token.get("access_token")
|
||||
expires_at_after = token.get("expires_at")
|
||||
id_token_after = token.get("id_token")
|
||||
|
||||
self.assertNotEqual(access_token_before, access_token_after)
|
||||
self.assertTrue(expires_at_after - expires_at_before > TOKEN_EXPIRES)
|
||||
self.assertNotEqual(id_token_before, id_token_after)
|
||||
|
||||
r = session.get(f"{server}/{logout_uri}")
|
||||
r = session.get(logout_uri)
|
||||
# check that the logout redirect worked
|
||||
self.assertEqual(r.history[0].status_code, 302)
|
||||
self.assertEqual(r.url, f"{server}")
|
||||
@@ -146,31 +161,16 @@ class AuthTest(unittest.TestCase):
|
||||
# test with session cookies
|
||||
app_config = AppConfig()
|
||||
app_config.update_server_config(
|
||||
authentication__type="oauth",
|
||||
authentication__params_oauth__api_base_url=f"http://localhost:{PORT}",
|
||||
authentication__params_oauth__client_id="mock_client_id",
|
||||
authentication__params_oauth__client_secret="mock_client_secret",
|
||||
authentication__params_oauth__session_cookie=True,
|
||||
)
|
||||
|
||||
app_config.update_server_config(multi_dataset__dataroot=self.dataset_dataroot)
|
||||
app_config.complete_config()
|
||||
|
||||
self.auth_flow(app_config)
|
||||
|
||||
def test_auth_oauth_cookie(self):
|
||||
# test with specified cookie
|
||||
app_config = AppConfig()
|
||||
app_config.update_server_config(
|
||||
authentication__type="oauth",
|
||||
authentication__params_oauth__api_base_url=f"http://localhost:{PORT}",
|
||||
authentication__params_oauth__client_id="mock_client_id",
|
||||
authentication__params_oauth__client_secret="mock_client_secret",
|
||||
authentication__params_oauth__session_cookie=False,
|
||||
authentication__params_oauth__cookie=dict(key="test_cxguser", httponly=True, max_age=60),
|
||||
)
|
||||
|
||||
app_config.update_server_config(multi_dataset__dataroot=self.dataset_dataroot)
|
||||
app_config.complete_config()
|
||||
|
||||
self.auth_flow(app_config, "test_cxguser")
|
||||
|
||||
@@ -7,6 +7,7 @@ import requests
|
||||
|
||||
from server.common.app_config import AppConfig
|
||||
from server.common.errors import ConfigurationError
|
||||
from server.common.utils.utils import find_available_port
|
||||
from server.test import PROJECT_ROOT, test_server, FIXTURES_ROOT
|
||||
|
||||
|
||||
@@ -19,46 +20,46 @@ def mockenv(**envvars):
|
||||
|
||||
class AppConfigTest(unittest.TestCase):
|
||||
def test_update(self):
|
||||
c = AppConfig()
|
||||
c.update_server_config(app__verbose=True, multi_dataset__dataroot="datadir")
|
||||
v = c.server_config.changes_from_default()
|
||||
self.assertCountEqual(v, [("app__verbose", True, False), ("multi_dataset__dataroot", "datadir", None)])
|
||||
config = AppConfig()
|
||||
config.update_server_config(app__verbose=True, multi_dataset__dataroot="datadir")
|
||||
vars = config.server_config.changes_from_default()
|
||||
self.assertCountEqual(vars, [("app__verbose", True, False), ("multi_dataset__dataroot", "datadir", None)])
|
||||
|
||||
c = AppConfig()
|
||||
c.update_default_dataset_config(app__scripts=(), app__inline_scripts=())
|
||||
v = c.server_config.changes_from_default()
|
||||
self.assertCountEqual(v, [])
|
||||
config = AppConfig()
|
||||
config.update_default_dataset_config(app__scripts=(), app__inline_scripts=())
|
||||
vars = config.server_config.changes_from_default()
|
||||
self.assertCountEqual(vars, [])
|
||||
|
||||
c = AppConfig()
|
||||
c.update_default_dataset_config(app__scripts=[], app__inline_scripts=[])
|
||||
v = c.default_dataset_config.changes_from_default()
|
||||
self.assertCountEqual(v, [])
|
||||
config = AppConfig()
|
||||
config.update_default_dataset_config(app__scripts=[], app__inline_scripts=[])
|
||||
vars = config.default_dataset_config.changes_from_default()
|
||||
self.assertCountEqual(vars, [])
|
||||
|
||||
c = AppConfig()
|
||||
c.update_default_dataset_config(app__scripts=("a", "b"), app__inline_scripts=["c", "d"])
|
||||
v = c.default_dataset_config.changes_from_default()
|
||||
self.assertCountEqual(v, [("app__scripts", ["a", "b"], []), ("app__inline_scripts", ["c", "d"], [])])
|
||||
config = AppConfig()
|
||||
config.update_default_dataset_config(app__scripts=("a", "b"), app__inline_scripts=["c", "d"])
|
||||
vars = config.default_dataset_config.changes_from_default()
|
||||
self.assertCountEqual(vars, [("app__scripts", ["a", "b"], []), ("app__inline_scripts", ["c", "d"], [])])
|
||||
|
||||
def test_multi_dataset(self):
|
||||
|
||||
c = AppConfig()
|
||||
config = AppConfig()
|
||||
# test for illegal url_dataroots
|
||||
for illegal in ("../b", "!$*", "\\n", "", "(bad)"):
|
||||
c.update_server_config(
|
||||
config.update_server_config(
|
||||
multi_dataset__dataroot={"tag": {"base_url": illegal, "dataroot": "{PROJECT_ROOT}/example-dataset"}}
|
||||
)
|
||||
with self.assertRaises(ConfigurationError):
|
||||
c.complete_config()
|
||||
config.complete_config()
|
||||
|
||||
# test for legal url_dataroots
|
||||
for legal in ("d", "this.is-okay_", "a/b"):
|
||||
c.update_server_config(
|
||||
config.update_server_config(
|
||||
multi_dataset__dataroot={"tag": {"base_url": legal, "dataroot": "{PROJECT_ROOT}/example-dataset"}}
|
||||
)
|
||||
c.complete_config()
|
||||
config.complete_config()
|
||||
|
||||
# test that multi dataroots work end to end
|
||||
c.update_server_config(
|
||||
config.update_server_config(
|
||||
multi_dataset__dataroot=dict(
|
||||
s1=dict(dataroot=f"{PROJECT_ROOT}/example-dataset", base_url="set1/1/2"),
|
||||
s2=dict(dataroot=f"{FIXTURES_ROOT}", base_url="set2"),
|
||||
@@ -67,46 +68,46 @@ class AppConfigTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
# Change this default to test if the dataroot overrides below work.
|
||||
c.update_default_dataset_config(app__about_legal_tos="tos_default.html")
|
||||
config.update_default_dataset_config(app__about_legal_tos="tos_default.html")
|
||||
|
||||
# specialize the configs for set1
|
||||
c.add_dataroot_config(
|
||||
config.add_dataroot_config(
|
||||
"s1", user_annotations__enable=False, diffexp__enable=True, app__about_legal_tos="tos_set1.html"
|
||||
)
|
||||
|
||||
# specialize the configs for set2
|
||||
c.add_dataroot_config(
|
||||
config.add_dataroot_config(
|
||||
"s2", user_annotations__enable=True, diffexp__enable=False, app__about_legal_tos="tos_set2.html"
|
||||
)
|
||||
|
||||
# no specializations for set3 (they get the default dataset config)
|
||||
c.complete_config()
|
||||
config.complete_config()
|
||||
|
||||
with test_server(app_config=c) as server:
|
||||
with test_server(app_config=config) as server:
|
||||
session = requests.Session()
|
||||
|
||||
r = session.get(f"{server}/set1/1/2/pbmc3k.h5ad/api/v0.2/config")
|
||||
data_config = r.json()
|
||||
response = session.get(f"{server}/set1/1/2/pbmc3k.h5ad/api/v0.2/config")
|
||||
data_config = response.json()
|
||||
assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k"
|
||||
assert data_config["config"]["parameters"]["annotations"] is False
|
||||
assert data_config["config"]["parameters"]["disable-diffexp"] is False
|
||||
assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_set1.html"
|
||||
|
||||
r = session.get(f"{server}/set2/pbmc3k.cxg/api/v0.2/config")
|
||||
data_config = r.json()
|
||||
response = session.get(f"{server}/set2/pbmc3k.cxg/api/v0.2/config")
|
||||
data_config = response.json()
|
||||
assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k"
|
||||
assert data_config["config"]["parameters"]["annotations"] is True
|
||||
assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_set2.html"
|
||||
|
||||
r = session.get(f"{server}/set3/pbmc3k.cxg/api/v0.2/config")
|
||||
data_config = r.json()
|
||||
response = session.get(f"{server}/set3/pbmc3k.cxg/api/v0.2/config")
|
||||
data_config = response.json()
|
||||
assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k"
|
||||
assert data_config["config"]["parameters"]["annotations"] is True
|
||||
assert data_config["config"]["parameters"]["disable-diffexp"] is False
|
||||
assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_default.html"
|
||||
|
||||
r = session.get(f"{server}/health")
|
||||
assert r.json()["status"] == "pass"
|
||||
response = session.get(f"{server}/health")
|
||||
assert response.json()["status"] == "pass"
|
||||
|
||||
@mockenv(CXG_AWS_SECRET_NAME="TESTING", CXG_AWS_SECRET_REGION_NAME="TEST_REGION")
|
||||
@patch('server.common.aws_secret_utils.get_secret_key')
|
||||
@@ -133,3 +134,23 @@ class AppConfigTest(unittest.TestCase):
|
||||
self.assertEqual(config.server_config.app__flask_secret_key, "mock_flask_secret")
|
||||
self.assertEqual(config.server_config.authentication__params_oauth__client_secret, "mock_oauth_secret")
|
||||
self.assertEqual(config.default_dataset_config.user_annotations__hosted_tiledb_array__db_uri, "mock_db_uri")
|
||||
|
||||
def test_api_base_url(self):
|
||||
|
||||
# test the api_base_url feature, and that it can contain a path
|
||||
config = AppConfig()
|
||||
backend_port = find_available_port("localhost", 10000)
|
||||
config.update_server_config(
|
||||
app__api_base_url=f"http://localhost:{backend_port}/additional/path/before/dataroot",
|
||||
multi_dataset__dataroot=f"{PROJECT_ROOT}/example-dataset"
|
||||
)
|
||||
|
||||
config.complete_config()
|
||||
|
||||
with test_server(["-p", str(backend_port)], app_config=config) as server:
|
||||
session = requests.Session()
|
||||
self.assertEqual(server, f"http://localhost:{backend_port}")
|
||||
response = session.get(f"{server}/additional/path/before/dataroot/d/pbmc3k.h5ad/api/v0.2/config")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data_config = response.json()
|
||||
self.assertEqual(data_config["config"]["displayNames"]["dataset"], "pbmc3k")
|
||||
|
||||
Reference in New Issue
Block a user