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:
bmccandless
2020-09-11 09:50:16 -07:00
committed by GitHub
parent 3f20f4a1f4
commit a7a4580944
8 changed files with 243 additions and 121 deletions
+43 -8
View File
@@ -2,6 +2,9 @@ import datetime
import logging import logging
from functools import wraps from functools import wraps
from http import HTTPStatus 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, \ from flask import Flask, redirect, current_app, make_response, render_template, abort, Blueprint, request, \
send_from_directory send_from_directory
@@ -84,10 +87,12 @@ def dataset_index(url_dataroot=None, dataset=None):
cache_manager = current_app.matrix_data_cache_manager cache_manager = current_app.matrix_data_cache_manager
with cache_manager.data_adaptor(url_dataroot, location, app_config) as data_adaptor: with cache_manager.data_adaptor(url_dataroot, location, app_config) as data_adaptor:
data_adaptor.set_uri_path(f"{url_dataroot}/{dataset}") data_adaptor.set_uri_path(f"{url_dataroot}/{dataset}")
dataset_title = app_config.get_title(data_adaptor) args = {
return render_template( "SCRIPTS" : scripts,
"index.html", datasetTitle=dataset_title, SCRIPTS=scripts, INLINE_SCRIPTS=inline_scripts "INLINE_SCRIPTS" : inline_scripts
) }
return render_template("index.html", **args)
except DatasetAccessError as e: except DatasetAccessError as e:
return common_rest.abort_and_log( return common_rest.abort_and_log(
e.status_code, f"Invalid dataset {dataset}: {e.message}", loglevel=logging.INFO, include_exc_info=True 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>" 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 auth.requires_client_login():
if server_config.auth.is_user_authenticated(): 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: else:
data += "<p><a href='/login'>Login</a></p>" data += f"<p><a href='{auth.get_login_url(None)}'>Login</a></p>"
datasets = [] datasets = []
for dataroot_dict in server_config.multi_dataset__dataroot.values(): 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 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: class Server:
@staticmethod @staticmethod
def _before_adding_routes(app, app_config): def _before_adding_routes(app, app_config):
@@ -337,6 +364,7 @@ class Server:
def __init__(self, app_config): def __init__(self, app_config):
self.app = Flask(__name__, static_folder=None) self.app = Flask(__name__, static_folder=None)
handle_api_base_url(self.app, app_config)
self._before_adding_routes(self.app, app_config) self._before_adding_routes(self.app, app_config)
self.app.json_encoder = Float32JSONEncoder self.app.json_encoder = Float32JSONEncoder
server_config = app_config.server_config server_config = app_config.server_config
@@ -353,6 +381,12 @@ class Server:
self.app.register_blueprint(webbp) self.app.register_blueprint(webbp)
api_version = "/api/v0.2" 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(): if app_config.is_multi_dataset():
# NOTE: These routes only allow the dataset to be in the directory # NOTE: These routes only allow the dataset to be in the directory
# of the dataroot, and not a subdirectory. We may want to change # 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(): for dataroot_dict in server_config.multi_dataset__dataroot.values():
url_dataroot = dataroot_dict["base_url"] url_dataroot = dataroot_dict["base_url"]
bp_api = Blueprint( 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) resources = get_api_resources(bp_api, url_dataroot)
self.app.register_blueprint(resources.blueprint) self.app.register_blueprint(resources.blueprint)
@@ -378,7 +413,7 @@ class Server:
) )
else: 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) resources = get_api_resources(bp_api)
self.app.register_blueprint(resources.blueprint) self.app.register_blueprint(resources.blueprint)
self.app.add_url_rule( self.app.add_url_rule(
+46 -32
View File
@@ -1,7 +1,7 @@
from flask import session, request, redirect, current_app, after_this_request, has_request_context, g from flask import session, request, redirect, current_app, after_this_request, has_request_context, g
from server.auth.auth import AuthTypeClientBase, AuthTypeFactory from server.auth.auth import AuthTypeClientBase, AuthTypeFactory
from server.common.errors import AuthenticationError, ConfigurationError from server.common.errors import AuthenticationError, ConfigurationError
from urllib.parse import urlencode from urllib.parse import urlencode, urlparse
import json import json
import requests import requests
import base64 import base64
@@ -45,13 +45,20 @@ class AuthTypeOAuth(AuthTypeClientBase):
if missingimport: if missingimport:
raise ConfigurationError(f"oauth requires these modules: {', '.join(missingimport)}") raise ConfigurationError(f"oauth requires these modules: {', '.join(missingimport)}")
self.algorithms = ["RS256"] 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_id = server_config.authentication__params_oauth__client_id
self.client_secret = server_config.authentication__params_oauth__client_secret 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.session_cookie = server_config.authentication__params_oauth__session_cookie
self.cookie_params = server_config.authentication__params_oauth__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_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 # set the audience
self.audience = self.client_id 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 # 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 # any JSON Web Token (JWT) issued by the authorization server and signed using the RS256
try: 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) jwksurl = requests.get(jwksloc)
self.jwks = jwksurl.json() self.jwks = jwksurl.json()
except Exception: 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): def _validate_cookie_params(self):
"""check the cookie_params, and raise a ConfigurationError if there is something wrong""" """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: if "key" not in keys:
raise ConfigurationError("must have a key (name) in the cookie params") 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): def is_valid_authentication_type(self):
return True return True
@@ -88,27 +111,22 @@ class AuthTypeOAuth(AuthTypeClientBase):
return True return True
def add_url_rules(self, app): def add_url_rules(self, app):
app.add_url_rule("/login", "login", self.login, methods=["GET"]) parse = urlparse(self.api_base_url)
app.add_url_rule("/logout", "logout", self.logout, methods=["GET"]) app.add_url_rule(f"{parse.path}/login", "login", self.login, methods=["GET"])
app.add_url_rule("/oauth2/callback", "callback", self.callback, 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): def complete_setup(self, flask_app):
self.oauth = OAuth(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( self.client = self.oauth.register(
"auth0", "auth0",
client_id=self.client_id, client_id=self.client_id,
client_secret=self.client_secret, client_secret=self.client_secret,
api_base_url=self.api_base_url, api_base_url=self.oauth_api_base_url,
refresh_token_url=f"{self.api_base_url}/oauth/token", refresh_token_url=f"{self.oauth_api_base_url}/oauth/token",
access_token_url=f"{self.api_base_url}/oauth/token", access_token_url=f"{self.oauth_api_base_url}/oauth/token",
authorize_url=f"{self.api_base_url}/authorize", authorize_url=f"{self.oauth_api_base_url}/authorize",
client_kwargs={"scope": "openid profile email offline_access"}, 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)) response.cache_control.update(dict(public=True, max_age=0, no_store=True, no_cache=True, must_revalidate=True))
def login(self): 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_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 # save the return path in the session cookie, accessed in the callback function
session["oauth_callback_redirect"] = return_to session["oauth_callback_redirect"] = return_to
response = self.client.authorize_redirect(redirect_uri=callbackurl) response = self.client.authorize_redirect(redirect_uri=callbackurl)
@@ -149,7 +167,7 @@ class AuthTypeOAuth(AuthTypeClientBase):
def logout(self): def logout(self):
self.remove_tokens() 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)) response = redirect(self.client.api_base_url + "/v2/logout?" + urlencode(params))
self.update_response(response) self.update_response(response)
return response return response
@@ -228,14 +246,14 @@ class AuthTypeOAuth(AuthTypeClientBase):
def get_login_url(self, data_adaptor): def get_login_url(self, data_adaptor):
"""Return the url for the login route""" """Return the url for the login route"""
if current_app.app_config.is_multi_dataset(): if data_adaptor and current_app.app_config.is_multi_dataset():
return f"/login?dataset={data_adaptor.uri_path}/" return f"{self.api_base_url}/login?dataset={data_adaptor.uri_path}/"
else: else:
return "/login" return f"{self.api_base_url}/login"
def get_logout_url(self, data_adaptor): def get_logout_url(self, data_adaptor):
"""Return the url for the logout route""" """Return the url for the logout route"""
return "/logout" return f"{self.api_base_url}/logout"
def check_jwt_payload(self, id_token): def check_jwt_payload(self, id_token):
try: try:
@@ -254,18 +272,14 @@ class AuthTypeOAuth(AuthTypeClientBase):
"e": key.get("e"), "e": key.get("e"),
} }
if rsa_key: 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: try:
payload = jwt.decode( payload = jwt.decode(
id_token, id_token,
rsa_key, rsa_key,
algorithms=self.algorithms, algorithms=self.algorithms,
audience=self.audience, audience=self.audience,
issuer=self.api_base_url + "/", issuer=self.oauth_api_base_url + "/",
options=options, options=self.jwt_decode_options,
) )
return payload return payload
@@ -321,7 +335,7 @@ class AuthTypeOAuth(AuthTypeClientBase):
"client_secret": self.client_secret, "client_secret": self.client_secret,
} }
headers = {"content-type": "application/x-www-form-urlencoded"} 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: if request.status_code != 200:
# unable to refresh the token, log the user out # unable to refresh the token, log the user out
self.remove_tokens() self.remove_tokens()
+1 -1
View File
@@ -296,7 +296,7 @@ class CliLaunchServer(Server):
"application/octet-stream", "application/octet-stream",
] ]
Compress(app) 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) CORS(app, supports_credentials=True)
+30 -6
View File
@@ -417,6 +417,7 @@ class ServerConfig(BaseConfig):
dictval_cases = [ dictval_cases = [
("app", "csp_directives"), ("app", "csp_directives"),
("authentication", "params_oauth", "cookie"), ("authentication", "params_oauth", "cookie"),
("authentication", "params_oauth", "jwt_decode_options"),
("adaptor", "cxg_adaptor", "tiledb_ctx"), ("adaptor", "cxg_adaptor", "tiledb_ctx"),
("multi_dataset", "dataroot"), ("multi_dataset", "dataroot"),
] ]
@@ -434,13 +435,18 @@ class ServerConfig(BaseConfig):
self.app__generate_cache_control_headers = dc["app"]["generate_cache_control_headers"] self.app__generate_cache_control_headers = dc["app"]["generate_cache_control_headers"]
self.app__server_timing_headers = dc["app"]["server_timing_headers"] self.app__server_timing_headers = dc["app"]["server_timing_headers"]
self.app__csp_directives = dc["app"]["csp_directives"] 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__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_id = dc["authentication"]["params_oauth"]["client_id"]
self.authentication__params_oauth__client_secret = dc["authentication"]["params_oauth"]["client_secret"] self.authentication__params_oauth__client_secret = dc["authentication"]["params_oauth"]["client_secret"]
self.authentication__params_oauth__callback_base_url = \ self.authentication__params_oauth__jwt_decode_options = dc["authentication"]["params_oauth"][
dc["authentication"]["params_oauth"]["callback_base_url"] "jwt_decode_options"]
self.authentication__params_oauth__session_cookie = dc["authentication"]["params_oauth"]["session_cookie"] self.authentication__params_oauth__session_cookie = dc["authentication"]["params_oauth"]["session_cookie"]
self.authentication__params_oauth__cookie = dc["authentication"]["params_oauth"]["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__flask_secret_key", (type(None), str))
self.check_attr("app__generate_cache_control_headers", bool) self.check_attr("app__generate_cache_control_headers", bool)
self.check_attr("app__server_timing_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__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: if self.app__port:
try: try:
@@ -549,15 +558,18 @@ class ServerConfig(BaseConfig):
elif not isinstance(v, str): elif not isinstance(v, str):
raise ConfigurationError("CSP directive value must be a string or list of strings.") 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): def handle_authentication(self, context):
self.check_attr("authentication__type", (type(None), str)) self.check_attr("authentication__type", (type(None), str))
# oauth # oauth
ptypes = str if self.authentication__type == "oauth" else (type(None), str) 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_id", ptypes)
self.check_attr("authentication__params_oauth__client_secret", 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) self.check_attr("authentication__params_oauth__session_cookie", bool)
if self.authentication__params_oauth__session_cookie: if self.authentication__params_oauth__session_cookie:
@@ -743,6 +755,18 @@ class ServerConfig(BaseConfig):
return False return False
return value > limit_value 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): class DatasetConfig(BaseConfig):
"""Manages the config attribute associated with a dataset.""" """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__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__db_uri = dc["user_annotations"]["hosted_tiledb_array"]["db_uri"]
self.user_annotations__hosted_tiledb_array__hosted_file_directory = \ 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__names = dc["embeddings"]["names"]
self.embeddings__enable_reembedding = dc["embeddings"]["enable_reembedding"] self.embeddings__enable_reembedding = dc["embeddings"]["enable_reembedding"]
+26 -6
View File
@@ -14,6 +14,25 @@ server:
server_timing_headers: false server_timing_headers: false
csp_directives: null 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: authentication:
# The authentication types may be "none", "session", "oauth" # The authentication types may be "none", "session", "oauth"
# none: No authentication support, features like user_annotations must not be enabled. # none: No authentication support, features like user_annotations must not be enabled.
@@ -22,16 +41,17 @@ server:
type: session type: session
params_oauth: params_oauth:
# url to the auth server # url to the oauth server
api_base_url: null oauth_api_base_url: null
# client_id of this app # client_id of this app
client_id: null client_id: null
# the client_secret known to the auth server and this app # the client_secret known to the auth server and this app
client_secret: null client_secret: null
# cellxgene server location; # jwt_decode_options, to specify non default decode options define
# the browser will be redirected to locations relative to this location during login and logout. # jwt_decode_options to be a dictionary with key/values described by
# A value of None, indicates the client and server are on the localhost. http://localhost:<port> will be used. # the options parameter of the jose.jwt.decode function:
callback_base_url: null # (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 # if true, the jwt containing the id_token is stored in a session cookie
session_cookie: true session_cookie: true
+13 -5
View File
@@ -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 where the server can be accessed within the context, and is terminated when
the context is exited. 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. The verbose flag is automatically set to True.
If an app_config is provided, then this function writes a temporary If an app_config is provided, then this function writes a temporary
yaml config file, which this server will read and parse. yaml config file, which this server will read and parse.
""" """
start = random.randint(DEFAULT_SERVER_PORT, 2 ** 16 - 1) command = ["cellxgene", "--no-upgrade-check", "launch", "--verbose"]
port = int(os.environ.get("CXG_SERVER_PORT", start)) if "-p" in command_line_args:
port = find_available_port("localhost", port) port = int(command_line_args[command_line_args.index("-p") + 1])
command = ["cellxgene", "--no-upgrade-check", "launch", "--verbose", "--port=%d" % port] + command_line_args 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 tempdir = None
if app_config: if app_config:
+28 -28
View File
@@ -19,7 +19,7 @@ from server.test import FIXTURES_ROOT, test_server
# oauth server. # oauth server.
# number of seconds that the oauth token is valid # 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. # Create a mocked out oauth token, which servers all the endpoints needed by the oauth type.
mock_oauth_app = Flask("mock_oauth_app") mock_oauth_app = Flask("mock_oauth_app")
@@ -34,17 +34,19 @@ def authorize():
@mock_oauth_app.route("/oauth/token", methods=["POST"]) @mock_oauth_app.route("/oauth/token", methods=["POST"])
def token(): def token():
now = time.time()
expires_at = now + TOKEN_EXPIRES
headers = dict(alg="RS256", kid="fake_kid") 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) jwt = jose.jwt.encode(claims=payload, key="mysecret", algorithm="HS256", headers=headers)
r = { r = {
"access_token": f"access-{time.time()}", "access_token": f"access-{now}",
"id_token": jwt, "id_token": jwt,
"refresh_token": f"random-{time.time()}", "refresh_token": f"random-{now}",
"scope": "openid profile email", "scope": "openid profile email",
"expires_in": TOKEN_EXPIRES, "expires_in": TOKEN_EXPIRES,
"token_type": "Bearer", "token_type": "Bearer",
"expires_at": time.time() + TOKEN_EXPIRES, "expires_at": expires_at
} }
return make_response(jsonify(r)) return make_response(jsonify(r))
@@ -81,6 +83,19 @@ class AuthTest(unittest.TestCase):
def auth_flow(self, app_config, cookie_key=None): 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: with test_server(app_config=app_config) as server:
session = requests.Session() session = requests.Session()
@@ -96,10 +111,10 @@ class AuthTest(unittest.TestCase):
login_uri = config["config"]["authentication"]["login"] login_uri = config["config"]["authentication"]["login"]
logout_uri = config["config"]["authentication"]["logout"] logout_uri = config["config"]["authentication"]["logout"]
self.assertEqual(login_uri, "/login?dataset=d/pbmc3k.cxg/") self.assertEqual(login_uri, f"{server}/login?dataset=d/pbmc3k.cxg/")
self.assertEqual(logout_uri, "/logout") 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 # check that the login redirect worked
self.assertEqual(r.history[0].status_code, 302) self.assertEqual(r.history[0].status_code, 302)
self.assertEqual(r.url, f"{server}/d/pbmc3k.cxg/") self.assertEqual(r.url, f"{server}/d/pbmc3k.cxg/")
@@ -113,13 +128,13 @@ class AuthTest(unittest.TestCase):
cookie = session.cookies.get(cookie_key) cookie = session.cookies.get(cookie_key)
token = json.loads(base64.b64decode(cookie)) token = json.loads(base64.b64decode(cookie))
access_token_before = token.get("access_token") 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 # let the token expire
time.sleep(TOKEN_EXPIRES + 1) time.sleep(TOKEN_EXPIRES + 1)
# check that refresh works # 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() userinfo = session.get(f"{server}/d/pbmc3k.cxg/api/v0.2/userinfo").json()
self.assertTrue(userinfo["userinfo"]["is_authenticated"]) self.assertTrue(userinfo["userinfo"]["is_authenticated"])
self.assertEqual(userinfo["userinfo"]["username"], "fake_user") self.assertEqual(userinfo["userinfo"]["username"], "fake_user")
@@ -127,12 +142,12 @@ class AuthTest(unittest.TestCase):
cookie = session.cookies.get(cookie_key) cookie = session.cookies.get(cookie_key)
token = json.loads(base64.b64decode(cookie)) token = json.loads(base64.b64decode(cookie))
access_token_after = token.get("access_token") 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.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 # check that the logout redirect worked
self.assertEqual(r.history[0].status_code, 302) self.assertEqual(r.history[0].status_code, 302)
self.assertEqual(r.url, f"{server}") self.assertEqual(r.url, f"{server}")
@@ -146,31 +161,16 @@ class AuthTest(unittest.TestCase):
# test with session cookies # test with session cookies
app_config = AppConfig() app_config = AppConfig()
app_config.update_server_config( 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, 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) self.auth_flow(app_config)
def test_auth_oauth_cookie(self): def test_auth_oauth_cookie(self):
# test with specified cookie # test with specified cookie
app_config = AppConfig() app_config = AppConfig()
app_config.update_server_config( 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__session_cookie=False,
authentication__params_oauth__cookie=dict(key="test_cxguser", httponly=True, max_age=60), 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") self.auth_flow(app_config, "test_cxguser")
+56 -35
View File
@@ -7,6 +7,7 @@ import requests
from server.common.app_config import AppConfig from server.common.app_config import AppConfig
from server.common.errors import ConfigurationError 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 from server.test import PROJECT_ROOT, test_server, FIXTURES_ROOT
@@ -19,46 +20,46 @@ def mockenv(**envvars):
class AppConfigTest(unittest.TestCase): class AppConfigTest(unittest.TestCase):
def test_update(self): def test_update(self):
c = AppConfig() config = AppConfig()
c.update_server_config(app__verbose=True, multi_dataset__dataroot="datadir") config.update_server_config(app__verbose=True, multi_dataset__dataroot="datadir")
v = c.server_config.changes_from_default() vars = config.server_config.changes_from_default()
self.assertCountEqual(v, [("app__verbose", True, False), ("multi_dataset__dataroot", "datadir", None)]) self.assertCountEqual(vars, [("app__verbose", True, False), ("multi_dataset__dataroot", "datadir", None)])
c = AppConfig() config = AppConfig()
c.update_default_dataset_config(app__scripts=(), app__inline_scripts=()) config.update_default_dataset_config(app__scripts=(), app__inline_scripts=())
v = c.server_config.changes_from_default() vars = config.server_config.changes_from_default()
self.assertCountEqual(v, []) self.assertCountEqual(vars, [])
c = AppConfig() config = AppConfig()
c.update_default_dataset_config(app__scripts=[], app__inline_scripts=[]) config.update_default_dataset_config(app__scripts=[], app__inline_scripts=[])
v = c.default_dataset_config.changes_from_default() vars = config.default_dataset_config.changes_from_default()
self.assertCountEqual(v, []) self.assertCountEqual(vars, [])
c = AppConfig() config = AppConfig()
c.update_default_dataset_config(app__scripts=("a", "b"), app__inline_scripts=["c", "d"]) config.update_default_dataset_config(app__scripts=("a", "b"), app__inline_scripts=["c", "d"])
v = c.default_dataset_config.changes_from_default() vars = config.default_dataset_config.changes_from_default()
self.assertCountEqual(v, [("app__scripts", ["a", "b"], []), ("app__inline_scripts", ["c", "d"], [])]) self.assertCountEqual(vars, [("app__scripts", ["a", "b"], []), ("app__inline_scripts", ["c", "d"], [])])
def test_multi_dataset(self): def test_multi_dataset(self):
c = AppConfig() config = AppConfig()
# test for illegal url_dataroots # test for illegal url_dataroots
for illegal in ("../b", "!$*", "\\n", "", "(bad)"): 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"}} multi_dataset__dataroot={"tag": {"base_url": illegal, "dataroot": "{PROJECT_ROOT}/example-dataset"}}
) )
with self.assertRaises(ConfigurationError): with self.assertRaises(ConfigurationError):
c.complete_config() config.complete_config()
# test for legal url_dataroots # test for legal url_dataroots
for legal in ("d", "this.is-okay_", "a/b"): 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"}} 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 # test that multi dataroots work end to end
c.update_server_config( config.update_server_config(
multi_dataset__dataroot=dict( multi_dataset__dataroot=dict(
s1=dict(dataroot=f"{PROJECT_ROOT}/example-dataset", base_url="set1/1/2"), s1=dict(dataroot=f"{PROJECT_ROOT}/example-dataset", base_url="set1/1/2"),
s2=dict(dataroot=f"{FIXTURES_ROOT}", base_url="set2"), 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. # 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 # 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" "s1", user_annotations__enable=False, diffexp__enable=True, app__about_legal_tos="tos_set1.html"
) )
# specialize the configs for set2 # 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" "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) # 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() session = requests.Session()
r = session.get(f"{server}/set1/1/2/pbmc3k.h5ad/api/v0.2/config") response = session.get(f"{server}/set1/1/2/pbmc3k.h5ad/api/v0.2/config")
data_config = r.json() data_config = response.json()
assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k" assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k"
assert data_config["config"]["parameters"]["annotations"] is False assert data_config["config"]["parameters"]["annotations"] is False
assert data_config["config"]["parameters"]["disable-diffexp"] is False assert data_config["config"]["parameters"]["disable-diffexp"] is False
assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_set1.html" assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_set1.html"
r = session.get(f"{server}/set2/pbmc3k.cxg/api/v0.2/config") response = session.get(f"{server}/set2/pbmc3k.cxg/api/v0.2/config")
data_config = r.json() data_config = response.json()
assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k" assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k"
assert data_config["config"]["parameters"]["annotations"] is True assert data_config["config"]["parameters"]["annotations"] is True
assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_set2.html" assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_set2.html"
r = session.get(f"{server}/set3/pbmc3k.cxg/api/v0.2/config") response = session.get(f"{server}/set3/pbmc3k.cxg/api/v0.2/config")
data_config = r.json() data_config = response.json()
assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k" assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k"
assert data_config["config"]["parameters"]["annotations"] is True assert data_config["config"]["parameters"]["annotations"] is True
assert data_config["config"]["parameters"]["disable-diffexp"] is False assert data_config["config"]["parameters"]["disable-diffexp"] is False
assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_default.html" assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_default.html"
r = session.get(f"{server}/health") response = session.get(f"{server}/health")
assert r.json()["status"] == "pass" assert response.json()["status"] == "pass"
@mockenv(CXG_AWS_SECRET_NAME="TESTING", CXG_AWS_SECRET_REGION_NAME="TEST_REGION") @mockenv(CXG_AWS_SECRET_NAME="TESTING", CXG_AWS_SECRET_REGION_NAME="TEST_REGION")
@patch('server.common.aws_secret_utils.get_secret_key') @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.app__flask_secret_key, "mock_flask_secret")
self.assertEqual(config.server_config.authentication__params_oauth__client_secret, "mock_oauth_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") 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")