diff --git a/conda-store-server/conda_store_server/server/app.py b/conda-store-server/conda_store_server/server/app.py
index 2378839ae..15ee41d7a 100644
--- a/conda-store-server/conda_store_server/server/app.py
+++ b/conda-store-server/conda_store_server/server/app.py
@@ -2,7 +2,7 @@
from flask import Flask
from flask_cors import CORS
-from traitlets import Bool, Unicode, Integer
+from traitlets import Bool, Unicode, Integer, Type
from traitlets.config import Application
from conda_store_server.server import views, auth
@@ -48,6 +48,19 @@ class CondaStoreServer(Application):
"conda_store_config.py", help="config file to load for conda-store", config=True
)
+ authentication_class = Type(
+ default_value=auth.DummyAuthentication,
+ klass=auth.Authentication,
+ allow_none=False,
+ config=True,
+ )
+
+ secret_key = Unicode(
+ "super_secret_key",
+ config=True,
+ help="A secret key needed for some authentication methods, session storage, etc.",
+ )
+
def initialize(self, *args, **kwargs):
super().initialize(*args, **kwargs)
self.load_config_file(self.config_file)
@@ -55,6 +68,7 @@ def initialize(self, *args, **kwargs):
def start(self):
app = Flask(__name__)
CORS(app, resources={r"/api/v1/*": {"origins": "*"}})
+ app.secret_key = self.secret_key
if self.enable_api:
app.register_blueprint(views.app_api)
@@ -69,7 +83,7 @@ def start(self):
app.register_blueprint(views.app_metrics)
app.conda_store = CondaStore(parent=self, log=self.log)
- app.authentication = auth.Authentication(parent=self, log=self.log)
+ app.authentication = self.authentication_class(parent=self, log=self.log)
# add dynamic routes
for route, method, func in app.authentication.routes:
diff --git a/conda-store-server/conda_store_server/server/auth.py b/conda-store-server/conda_store_server/server/auth.py
index 96baf9241..127376b63 100644
--- a/conda-store-server/conda_store_server/server/auth.py
+++ b/conda-store-server/conda_store_server/server/auth.py
@@ -4,9 +4,19 @@
import datetime
import jwt
+import requests
from traitlets.config import LoggingConfigurable
-from traitlets import Dict, Unicode, Type
-from flask import request, render_template, redirect, g, abort, jsonify
+from traitlets import Dict, Unicode, Type, default
+from flask import (
+ request,
+ render_template,
+ redirect,
+ g,
+ abort,
+ jsonify,
+ url_for,
+ session,
+)
from sqlalchemy import or_, and_
from conda_store_server import schema, orm
@@ -170,7 +180,7 @@ class Authentication(LoggingConfigurable):
-
+
""",
@@ -178,6 +188,9 @@ class Authentication(LoggingConfigurable):
config=True,
)
+ def get_login_html(self):
+ return self.login_html
+
@property
def authentication(self):
if hasattr(self, "_authentication"):
@@ -209,10 +222,10 @@ def authenticate(self, request):
)
def get_login_method(self):
- return render_template("login.html", login_html=self.login_html)
+ return render_template("login.html", login_html=self.get_login_html())
def post_login_method(self):
- redirect_url = request.args.get("next", "/")
+ redirect_url = request.args.get("next", url_for("ui.ui_get_user"))
response = redirect(redirect_url)
authentication_token = self.authenticate(request)
if authentication_token is None:
@@ -331,3 +344,234 @@ def filter_namespaces(self, query):
return query.filter(False)
return query.filter(or_(*cases))
+
+
+class DummyAuthentication(Authentication):
+ """Dummy Authentication for testing
+ By default, any username + password is allowed
+ If a non-empty password is set, any username will be allowed
+ if it logs in with that password.
+ """
+
+ password = Unicode(
+ "password",
+ config=True,
+ help="""
+ Set a global password for all users wanting to log in.
+ This allows users with any username to log in with the same static password.
+ """,
+ )
+
+ # login_html = Unicode()
+
+ def authenticate(self, request):
+ """Checks against a global password if it's been set. If not, allow any user/pass combo"""
+ if self.password and request.form["password"] != self.password:
+ return None
+
+ return schema.AuthenticationToken(
+ primary_namespace=request.form["username"],
+ role_bindings={
+ "*/*": ["admin"],
+ },
+ )
+
+
+class GenericOAuthAuthentication(Authentication):
+ """
+ A provider-agnostic OAuth authentication provider. Configure endpoints, secrets and other
+ parameters to enable any OAuth-compatible platform.
+ """
+
+ access_token_url = Unicode(
+ config=True,
+ help="URL used to request an access token once app has been authorized",
+ )
+ authorize_url = Unicode(
+ config=True,
+ help="URL used to request authorization to OAuth provider",
+ )
+ client_id = Unicode(
+ config=True,
+ help="Unique string that identifies the app against the OAuth provider",
+ )
+ client_secret = Unicode(
+ config=True,
+ help="Secret string used to authenticate the app against the OAuth provider",
+ )
+ access_scope = Unicode(
+ config=True,
+ help="Permissions that will be requested to OAuth provider.",
+ )
+ user_data_url = Unicode(
+ config=True,
+ help="API endpoint for OAuth provider that returns a JSON dict with user data",
+ )
+ user_data_key = Unicode(
+ config=True,
+ help="Key in the payload returned by `user_data_url` endpoint that provides the username",
+ )
+ login_html = Unicode(
+ """
+
+
Please sign in via OAuth
+
Sign in with OAuth
+
+ """,
+ help="html form to use for login",
+ config=True,
+ )
+
+ def get_login_html(self):
+ state = secrets.token_urlsafe()
+ session["oauth_state"] = state
+ authorization_url = self.oauth_route(
+ auth_url=self.authorize_url,
+ client_id=self.client_id,
+ redirect_uri=url_for("post_login_method", _external=True),
+ scope=self.access_scope,
+ state=state,
+ )
+ return self.login_html.format(authorization_url=authorization_url)
+
+ @staticmethod
+ def oauth_route(auth_url, client_id, redirect_uri, scope=None, state=None):
+ r = f"{auth_url}?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code"
+ if scope is not None:
+ r += f"&scope={scope}"
+ if state is not None:
+ r += f"&state={state}"
+ return r
+
+ @property
+ def routes(self):
+ return [
+ ("/login/", "GET", self.get_login_method),
+ ("/logout/", "POST", self.post_logout_method),
+ ("/oauth_callback/", "GET", self.post_login_method),
+ ]
+
+ def authenticate(self, request):
+ # 1. using the callback_url code and state in request
+ oauth_access_token = self._get_oauth_token(request)
+ if oauth_access_token is None:
+ return None # authentication failed
+
+ # 2. Who is the username? We need one more request
+ username = self._get_username(oauth_access_token)
+
+ # 3. create our own internal token
+ return schema.AuthenticationToken(
+ primary_namespace=username,
+ role_bindings={
+ "*/*": ["admin"],
+ },
+ )
+
+ def _get_oauth_token(self, request):
+ # 1. Get callback URI params, which include `code` and `state`
+ # `code` will be used to request the token; `state` must match our session's!
+ code = request.args.get("code")
+ state = request.args.get("state")
+ if session["oauth_state"] != state:
+ response = jsonify(
+ {"status": "error", "message": "OAuth states do not match"}
+ )
+ response.status_code = 401
+ abort(response)
+ del session["oauth_state"]
+
+ # 2. Request actual access token with code and secret
+ r_response = requests.post(
+ self.access_token_url,
+ data={
+ "code": code,
+ "grant_type": "authorization_code",
+ "client_id": self.client_id,
+ "client_secret": self.client_secret,
+ },
+ headers={"Accept": "application/json"},
+ )
+ if r_response.status_code != 200:
+ return None
+ data = r_response.json()
+ return data["access_token"]
+
+ def _get_username(self, authentication_token):
+ response = requests.get(
+ self.user_data_url,
+ headers={"Authorization": f"token {authentication_token}"},
+ )
+ response.raise_for_status()
+ return response.json()[self.user_data_key]
+
+
+class GithubOAuthAuthentication(GenericOAuthAuthentication):
+ github_url = Unicode("https://github.com", config=True)
+
+ github_api = Unicode("https://api.github.com", config=True)
+
+ @default("access_token_url")
+ def _access_token_url_default(self):
+ return "%s/login/oauth/access_token" % (self.github_url)
+
+ @default("authorize_url")
+ def _authorize_url_default(self):
+ return "%s/login/oauth/authorize" % (self.github_url)
+
+ @default("access_scope")
+ def _access_scope_default(self):
+ return "user:email"
+
+ @default("user_data_url")
+ def _user_data_url_default(self):
+ return "%s/user" % (self.github_api)
+
+ @default("user_data_key")
+ def _user_data_key_default(self):
+ return "login"
+
+ @default("login_html")
+ def _login_html_default(self):
+ return """
+
+
Please sign in via OAuth
+
Sign in with GitHub
+
+ """
+
+
+class JupyterHubOAuthAuthentication(GenericOAuthAuthentication):
+ jupyterhub_url = Unicode(
+ help="base url for jupyterhub not including the '/hub/'",
+ config=True,
+ )
+
+ @default("access_token_url")
+ def _access_token_url_default(self):
+ return "%s/hub/api/oauth2/token" % (self.jupyterhub_url)
+
+ @default("authorize_url")
+ def _authorize_url_default(self):
+ return "%s/hub/api/oauth2/authorize" % (self.jupyterhub_url)
+
+ @default("access_scope")
+ def _access_scope_default(self):
+ return "profile"
+
+ @default("user_data_url")
+ def _user_data_url_default(self):
+ return "%s/hub/api/user" % (self.jupyterhub_url)
+
+ @default("user_data_key")
+ def _user_data_key_default(self):
+ return "name"
+
+ @default("login_html")
+ def _login_html_default(self):
+ return """
+
+
Please sign in via OAuth
+
Sign in with JupyterHub
+
+ """
diff --git a/conda-store-server/conda_store_server/server/templates/navigation.html b/conda-store-server/conda_store_server/server/templates/navigation.html
index 20dafcb91..75d55f71d 100644
--- a/conda-store-server/conda_store_server/server/templates/navigation.html
+++ b/conda-store-server/conda_store_server/server/templates/navigation.html
@@ -14,9 +14,9 @@
{% else %}
-
+
+ User
+
{% endif %}
diff --git a/conda-store-server/conda_store_server/server/templates/user.html b/conda-store-server/conda_store_server/server/templates/user.html
new file mode 100644
index 000000000..3f525f8b5
--- /dev/null
+++ b/conda-store-server/conda_store_server/server/templates/user.html
@@ -0,0 +1,19 @@
+{% extends 'base.html' %}
+
+{% block title %}User info{% endblock %}
+
+{% block content %}
+
+{% block user %}
+
+
Logged in as {{ username }}
+ {% if email is defined %}
+
Email: {{ email }}
+ {% endif %}
+
+
+{% endblock %}
+
+{% endblock %}
diff --git a/conda-store-server/conda_store_server/server/views/ui.py b/conda-store-server/conda_store_server/server/views/ui.py
index 716482dcb..ff637034b 100644
--- a/conda-store-server/conda_store_server/server/views/ui.py
+++ b/conda-store-server/conda_store_server/server/views/ui.py
@@ -159,6 +159,18 @@ def ui_get_build(build_id):
return render_template("build.html", **context)
+@app_ui.route("/user/", methods=["GET"])
+def ui_get_user():
+ auth = get_auth()
+
+ entity = auth.authenticate_request()
+ if entity is None:
+ return redirect("/login/")
+
+ context = {"username": entity.primary_namespace}
+ return render_template("user.html", **context)
+
+
@app_ui.route("/build//logs/", methods=["GET"])
def api_get_build_logs(build_id):
conda_store = get_conda_store()
diff --git a/docs/development.md b/docs/development.md
index c71875300..6cbcb4c21 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -45,3 +45,7 @@ These commands should build the extension. Then,
`jupyter lab`
+## Note about Response objects
+
+We use both `flask` and `requests`, which both return `response` objects. In case of ambiguity,
+we prefix them like `f_response` and `r_response`, respectively.
\ No newline at end of file
diff --git a/tests/assets/conda_store_config.py b/tests/assets/conda_store_config.py
index c9261f5bd..234a2ec09 100644
--- a/tests/assets/conda_store_config.py
+++ b/tests/assets/conda_store_config.py
@@ -1,6 +1,7 @@
import logging
from conda_store_server.storage import S3Storage
+from conda_store_server.server.auth import JupyterHubOAuthAuthentication
# ==================================
# conda-store settings
@@ -32,6 +33,21 @@
c.CondaStoreServer.address = "0.0.0.0"
c.CondaStoreServer.port = 5000
+
+# ==================================
+# auth settings
+# ==================================
+c.CondaStoreServer.authentication_class = JupyterHubOAuthAuthentication
+c.JupyterHubOAuthAuthentication.jupyterhub_url = "http://jupyterhub:8000"
+c.JupyterHubOAuthAuthentication.client_id = "service-this-is-a-jupyterhub-client"
+c.JupyterHubOAuthAuthentication.client_secret = "this-is-a-jupyterhub-secret"
+# in the case of docker-compose the internal and external dns
+# routes do not match. Inside the docker compose deployment
+# jupyterhub is accessible via the `jupyterhub` hostname in dns
+# however outside of the docker it is accessible via localhost
+# hence this small change needed for testing
+c.JupyterHubOAuthAuthentication.authorize_url = "http://localhost:8000/hub/api/oauth2/authorize"
+
# ==================================
# worker settings
# ==================================
diff --git a/tests/assets/jupyterhub_config.py b/tests/assets/jupyterhub_config.py
index e99b5de4c..5dbe37fb6 100644
--- a/tests/assets/jupyterhub_config.py
+++ b/tests/assets/jupyterhub_config.py
@@ -5,3 +5,13 @@
c.Spawner.cmd=["jupyter-labhub"]
c.JupyterHub.spawner_class = 'jupyterhub.spawner.SimpleLocalProcessSpawner'
+
+c.JupyterHub.services = [
+ {
+ 'name': "conda-store",
+ 'oauth_client_id': "service-this-is-a-jupyterhub-client",
+ 'admin': True,
+ 'api_token': "this-is-a-jupyterhub-secret",
+ 'oauth_redirect_uri': 'http://localhost:5000/oauth_callback/',
+ }
+]