about summary refs log tree commit diff
path: root/gn2/wqflask/oauth2
diff options
context:
space:
mode:
authorArun Isaac2023-12-29 18:55:37 +0000
committerArun Isaac2023-12-29 19:01:46 +0000
commit204a308be0f741726b9a620d88fbc22b22124c81 (patch)
treeb3cf66906674020b530c844c2bb4982c8a0e2d39 /gn2/wqflask/oauth2
parent83062c75442160427b50420161bfcae2c5c34c84 (diff)
downloadgenenetwork2-204a308be0f741726b9a620d88fbc22b22124c81.tar.gz
Namespace all modules under gn2.
We move all modules under a gn2 directory. This is important for
"correct" packaging and deployment as a Guix service.
Diffstat (limited to 'gn2/wqflask/oauth2')
-rw-r--r--gn2/wqflask/oauth2/__init__.py0
-rw-r--r--gn2/wqflask/oauth2/checks.py49
-rw-r--r--gn2/wqflask/oauth2/client.py124
-rw-r--r--gn2/wqflask/oauth2/collections.py16
-rw-r--r--gn2/wqflask/oauth2/data.py319
-rw-r--r--gn2/wqflask/oauth2/groups.py210
-rw-r--r--gn2/wqflask/oauth2/request_utils.py99
-rw-r--r--gn2/wqflask/oauth2/resources.py294
-rw-r--r--gn2/wqflask/oauth2/roles.py99
-rw-r--r--gn2/wqflask/oauth2/routes.py18
-rw-r--r--gn2/wqflask/oauth2/session.py111
-rw-r--r--gn2/wqflask/oauth2/toplevel.py57
-rw-r--r--gn2/wqflask/oauth2/ui.py23
-rw-r--r--gn2/wqflask/oauth2/users.py190
14 files changed, 1609 insertions, 0 deletions
diff --git a/gn2/wqflask/oauth2/__init__.py b/gn2/wqflask/oauth2/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/gn2/wqflask/oauth2/__init__.py
diff --git a/gn2/wqflask/oauth2/checks.py b/gn2/wqflask/oauth2/checks.py
new file mode 100644
index 00000000..5d90f986
--- /dev/null
+++ b/gn2/wqflask/oauth2/checks.py
@@ -0,0 +1,49 @@
+"""Various checkers for OAuth2"""
+from functools import wraps
+from urllib.parse import urljoin
+
+from authlib.integrations.requests_client import OAuth2Session
+from flask import (
+    flash, request, url_for, redirect, current_app, session as flask_session)
+
+from . import session
+
+def user_logged_in():
+    """Check whether the user has logged in."""
+    suser = session.session_info()["user"]
+    if suser["logged_in"]:
+        if session.expired():
+            session.clear_session_info()
+            return False
+        return suser["token"].is_right()
+    return False
+
+def require_oauth2(func):
+    """Decorator for ensuring user is logged in."""
+    @wraps(func)
+    def __token_valid__(*args, **kwargs):
+        """Check that the user is logged in and their token is valid."""
+        config = current_app.config
+        def __clear_session__(_no_token):
+            session.clear_session_info()
+            flask_session.pop("oauth2_token", None)
+            flask_session.pop("user_details", None)
+            flash("You need to be logged in.", "alert-warning")
+            return redirect("/")
+
+        def __with_token__(token):
+            from gn2.utility.tools import (
+                AUTH_SERVER_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET)
+            client = OAuth2Session(
+                OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, token=token)
+            resp = client.get(
+                urljoin(AUTH_SERVER_URL, "auth/user/"))
+            user_details = resp.json()
+            if not user_details.get("error", False):
+                return func(*args, **kwargs)
+
+            return clear_session_info(token)
+
+        return session.user_token().either(__clear_session__, __with_token__)
+
+    return __token_valid__
diff --git a/gn2/wqflask/oauth2/client.py b/gn2/wqflask/oauth2/client.py
new file mode 100644
index 00000000..c6a3110b
--- /dev/null
+++ b/gn2/wqflask/oauth2/client.py
@@ -0,0 +1,124 @@
+"""Common oauth2 client utilities."""
+import json
+import requests
+from typing import Any, Optional
+from urllib.parse import urljoin
+
+from flask import jsonify, current_app as app
+from pymonad.maybe import Just, Maybe, Nothing
+from pymonad.either import Left, Right, Either
+from authlib.integrations.requests_client import OAuth2Session
+
+from gn2.wqflask.oauth2 import session
+from gn2.wqflask.oauth2.checks import user_logged_in
+
+SCOPE = ("profile group role resource register-client user masquerade "
+         "introspect migrate-data")
+
+def oauth2_client():
+    def __client__(token) -> OAuth2Session:
+        from gn2.utility.tools import (
+            AUTH_SERVER_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET)
+        return OAuth2Session(
+            OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET,
+            scope=SCOPE, token_endpoint_auth_method="client_secret_post",
+            token=token)
+    return session.user_token().either(
+        lambda _notok: __client__(None),
+        lambda token: __client__(token))
+
+def __no_token__(_err) -> Left:
+    """Handle situation where request is attempted with no token."""
+    resp = requests.models.Response()
+    resp._content = json.dumps({
+        "error": "AuthenticationError",
+        "error-description": ("You need to authenticate to access requested "
+                              "information.")}).encode("utf-8")
+    resp.status_code = 400
+    return Left(resp)
+
+def oauth2_get(uri_path: str, data: dict = {}, **kwargs) -> Either:
+    def __get__(token) -> Either:
+        from gn2.utility.tools import (
+            AUTH_SERVER_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET)
+        client = OAuth2Session(
+            OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET,
+            token=token, scope=SCOPE)
+        resp = client.get(
+            urljoin(AUTH_SERVER_URL, uri_path),
+            data=data,
+            **kwargs)
+        if resp.status_code == 200:
+            return Right(resp.json())
+
+        return Left(resp)
+
+    return session.user_token().either(__no_token__, __get__)
+
+def oauth2_post(
+        uri_path: str, data: Optional[dict] = None, json: Optional[dict] = None,
+        **kwargs) -> Either:
+    def __post__(token) -> Either:
+        from gn2.utility.tools import (
+            AUTH_SERVER_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET)
+        client = OAuth2Session(
+            OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET,
+            token=token, scope=SCOPE)
+        resp = client.post(
+            urljoin(AUTH_SERVER_URL, uri_path), data=data, json=json,
+            **kwargs)
+        if resp.status_code == 200:
+            return Right(resp.json())
+
+        return Left(resp)
+
+    return session.user_token().either(__no_token__, __post__)
+
+def no_token_get(uri_path: str, **kwargs) -> Either:
+    from gn2.utility.tools import AUTH_SERVER_URL
+    resp = requests.get(urljoin(AUTH_SERVER_URL, uri_path), **kwargs)
+    if resp.status_code == 200:
+        return Right(resp.json())
+    return Left(resp)
+
+def no_token_post(uri_path: str, **kwargs) -> Either:
+    from gn2.utility.tools import (
+        AUTH_SERVER_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET)
+    data = kwargs.get("data", {})
+    the_json = kwargs.get("json", {})
+    request_data = {
+        **data,
+        **the_json,
+        "client_id": OAUTH2_CLIENT_ID,
+        "client_secret": OAUTH2_CLIENT_SECRET
+    }
+    new_kwargs = {
+        **{
+            key: value for key, value in kwargs.items()
+            if key not in ("data", "json")
+        },
+        ("data" if bool(data) else "json"): request_data
+    }
+    resp = requests.post(urljoin(AUTH_SERVER_URL, uri_path),
+                         **new_kwargs)
+    if resp.status_code == 200:
+        return Right(resp.json())
+    return Left(resp)
+
+def post(uri_path: str, **kwargs) -> Either:
+    """
+    Generic function to do POST requests, that checks whether or not the user is
+    logged in and selects the appropriate function/method to run.
+    """
+    if user_logged_in():
+        return oauth2_post(uri_path, **kwargs)
+    return no_token_post(uri_path, **kwargs)
+
+def get(uri_path: str, **kwargs) -> Either:
+    """
+    Generic function to do GET requests, that checks whether or not the user is
+    logged in and selects the appropriate function/method to run.
+    """
+    if user_logged_in():
+        return oauth2_get(uri_path, **kwargs)
+    return no_token_get(uri_path, **kwargs)
diff --git a/gn2/wqflask/oauth2/collections.py b/gn2/wqflask/oauth2/collections.py
new file mode 100644
index 00000000..63bf206e
--- /dev/null
+++ b/gn2/wqflask/oauth2/collections.py
@@ -0,0 +1,16 @@
+"""Functions for collections."""
+from .session import session_info
+from .checks import user_logged_in
+from .client import oauth2_get, no_token_get
+
+def num_collections() -> int:
+    """Compute the number of collections available for the current session."""
+    anon_id = session_info()["anon_id"]
+    all_collections = no_token_get(
+        f"auth/user/collections/{anon_id}/list").either(
+            lambda _err: [], lambda colls: colls)
+    if user_logged_in():
+        all_collections = all_collections + oauth2_get(
+            "auth/user/collections/list").either(
+                lambda _err: [], lambda colls: colls)
+    return len(all_collections)
diff --git a/gn2/wqflask/oauth2/data.py b/gn2/wqflask/oauth2/data.py
new file mode 100644
index 00000000..a1dfdf95
--- /dev/null
+++ b/gn2/wqflask/oauth2/data.py
@@ -0,0 +1,319 @@
+"""Handle linking data to groups."""
+import sys
+import json
+import uuid
+from datetime import datetime
+from urllib.parse import urljoin
+
+from redis import Redis
+from flask import (
+    flash, request, jsonify, url_for, redirect, Response, Blueprint,
+    current_app as app)
+
+from gn2.wqflask.oauth2.request_utils import with_flash_error
+
+from gn2.jobs import jobs
+from .ui import render_ui
+from .request_utils import process_error
+from .client import oauth2_get, oauth2_post
+
+data = Blueprint("data", __name__)
+
+def __search_mrna__(query, template, **kwargs):
+    from gn2.utility.tools import AUTH_SERVER_URL
+    species_name = kwargs["species_name"]
+    search_uri = urljoin(AUTH_SERVER_URL, "auth/data/search")
+    datasets = oauth2_get(
+        "auth/data/search",
+        json = {
+            "query": query,
+            "dataset_type": "mrna",
+            "species_name": species_name,
+            "selected": __selected_datasets__()
+        }).either(
+            lambda err: {"datasets_error": process_error(err)},
+            lambda datasets: {"datasets": datasets})
+    return render_ui(template, search_uri=search_uri, **datasets, **kwargs)
+
+def __selected_datasets__():
+    if bool(request.json):
+        return request.json.get(
+            "selected",
+            request.args.get("selected",
+                             request.form.get("selected", [])))
+    return request.args.get("selected",
+                            request.form.get("selected", []))
+
+def __search_genotypes__(query, template, **kwargs):
+    from gn2.utility.tools import AUTH_SERVER_URL
+    species_name = kwargs["species_name"]
+    search_uri = urljoin(AUTH_SERVER_URL, "auth/data/search")
+    datasets = oauth2_get(
+        "auth/data/search",
+        json = {
+            "query": query,
+            "dataset_type": "genotype",
+            "species_name": species_name,
+            "selected": __selected_datasets__()
+        }).either(
+            lambda err: {"datasets_error": process_error(err)},
+            lambda datasets: {"datasets": datasets})
+    return render_ui(template, search_uri=search_uri, **datasets, **kwargs)
+
+def __search_phenotypes__(query, template, **kwargs):
+    from gn2.utility.tools import GN_SERVER_URL, AUTH_SERVER_URL
+    page = int(request.args.get("page", 1))
+    per_page = int(request.args.get("per_page", 50))
+    selected_traits = request.form.getlist("selected_traits")
+    def __search_success__(search_results):
+        job_id = uuid.UUID(search_results["job_id"])
+        return render_ui(
+            template, traits=[], per_page=per_page, query=query,
+            selected_traits=selected_traits, search_results=search_results,
+            search_endpoint=urljoin(
+                AUTH_SERVER_URL, "auth/data/search"),
+            gn_server_url = AUTH_SERVER_URL,
+            results_endpoint=urljoin(
+                AUTH_SERVER_URL,
+                f"auth/data/search/phenotype/{job_id}"),
+            **kwargs)
+    return oauth2_get("auth/data/search", json={
+        "dataset_type": "phenotype",
+        "species_name": kwargs["species_name"],
+        "per_page": per_page,
+        "page": page,
+        "gn3_server_uri": GN_SERVER_URL
+    }).either(
+        with_flash_error(redirect(url_for('oauth2.data.list_data'))),
+        __search_success__)
+
+@data.route("/genotype/search", methods=["POST"])
+def json_search_genotypes() -> Response:
+    def __handle_error__(err):
+        error = process_error(err)
+        return jsonify(error), error["status_code"]
+    
+    return oauth2_get(
+        "auth/data/search",
+        json = {
+            "query": request.json["query"],
+            "dataset_type": "genotype",
+            "species_name": request.json["species_name"],
+            "selected": __selected_datasets__()
+        }).either(
+            __handle_error__,
+            lambda datasets: jsonify(datasets))
+
+@data.route("/mrna/search", methods=["POST"])
+def json_search_mrna() -> Response:
+    def __handle_error__(err):
+        error = process_error(err)
+        return jsonify(error), error["status_code"]
+
+    return oauth2_get(
+        "auth/data/search",
+        json = {
+            "query": request.json["query"],
+            "dataset_type": "mrna",
+            "species_name": request.json["species_name"],
+            "selected": __selected_datasets__()
+        }).either(
+            __handle_error__,
+            lambda datasets: jsonify(datasets))
+
+@data.route("/phenotype/search", methods=["POST"])
+def json_search_phenotypes() -> Response:
+    """Search for phenotypes."""
+    from gn2.utility.tools import AUTH_SERVER_URL
+    form = request.json
+    def __handle_error__(err):
+        error = process_error(err)
+        return jsonify(error), error["status_code"]
+
+    return oauth2_get(
+        "auth/data/search",
+        json={
+            "dataset_type": "phenotype",
+            "species_name": form["species_name"],
+            "query": form.get("query", ""),
+            "per_page": int(form.get("per_page", 50)),
+            "page": int(form.get("page", 1)),
+            "auth_server_uri": AUTH_SERVER_URL,
+            "selected_traits": form.get("selected_traits", [])
+        }).either(__handle_error__, jsonify)
+
+@data.route("/<string:species_name>/<string:dataset_type>/list",
+            methods=["GET", "POST"])
+def list_data_by_species_and_dataset(
+        species_name: str, dataset_type: str) -> Response:
+    templates = {
+        "mrna": "oauth2/data-list-mrna.html",
+        "genotype": "oauth2/data-list-genotype.html",
+        "phenotype": "oauth2/data-list-phenotype.html"
+    }
+    search_fns = {
+        "mrna": __search_mrna__,
+        "genotype": __search_genotypes__,
+        "phenotype": __search_phenotypes__
+    }
+    groups = oauth2_get("auth/group/list").either(
+        lambda err: {"groups_error": process_error(err)},
+        lambda grps: {"groups": grps})
+    query = request.args.get("query", "")
+    return search_fns[dataset_type](
+        query, templates[dataset_type], **groups, species_name=species_name,
+        dataset_type=dataset_type)
+
+@data.route("/list", methods=["GET", "POST"])
+def list_data():
+    """List ungrouped data."""
+    def __render__(**kwargs):
+        roles = kwargs.get("roles", [])
+        user_privileges = tuple(
+                privilege["privilege_id"] for role in roles
+                for privilege in role["privileges"])
+        return render_ui(
+            "oauth2/data-list.html",
+            groups=kwargs.get("groups", []),
+            data_items=kwargs.get("data_items", []),
+            user_privileges=user_privileges,
+            **{key:val for key,val in kwargs.items()
+               if key not in ("groups", "data_items", "user_privileges")})
+
+    groups = oauth2_get("auth/group/list").either(
+        lambda err: {"groups_error": process_error(err)},
+        lambda grp: {"groups": grp})
+    roles = oauth2_get("auth/system/roles").either(
+        lambda err: {"roles_error": process_error(err)},
+        lambda roles: {"roles": roles})
+    species = oauth2_get("auth/data/species").either(
+        lambda err: {"species_error": process_error(err)},
+        lambda species: {"species": species})
+
+    if request.method == "GET":
+        return __render__(**{**groups, **roles, **species})
+
+    species_name = request.form["species_name"]
+    dataset_type = request.form["dataset_type"]
+    if dataset_type not in ("mrna", "genotype", "phenotype"):
+        flash("InvalidDatasetType: An invalid dataset type was provided",
+              "alert-danger")
+        return __render__(**{**groups, **roles, **species})
+
+    return redirect(url_for(
+        "oauth2.data.list_data_by_species_and_dataset",
+        species_name=species_name, dataset_type=dataset_type))
+
+@data.route("/link", methods=["POST"])
+def link_data():
+    """Link the selected data to a specific group."""
+    def __error__(err, form_data):
+        error = process_error(err)
+        flash(f"{error['error']}: {error['error_description']}", "alert-danger")
+        return redirect(url_for("oauth2.data.list_data", **form_data), code=307)
+    def __success__(success, form_data):
+        flash("Data successfully linked!", "alert-success")
+        return redirect(url_for("oauth2.data.list_data", **form_data), code=307)
+
+    form = request.form
+    try:
+        keys = ("dataset_type", "group_id")
+        assert all(item in form for item in keys)
+        assert all(bool(form[item]) for item in keys)
+        state_data = {
+            "dataset_type": form["dataset_type"],
+            "offset": form.get("offset", 0)}
+        dataset_ids = form.getlist("dataset_ids")
+        if len(dataset_ids) == 0:
+            flash("You must select at least one item to link", "alert-danger")
+            return redirect(url_for(
+                "oauth2.data.list_data", **state_data))
+        return oauth2_post(
+            "auth/group/data/link",
+            data={
+                "dataset_type": form["dataset_type"],
+                "dataset_ids": dataset_ids,
+                "group_id": form["group_id"]
+            }).either(lambda err: __error__(err, state_data),
+                      lambda success: __success__(success, state_data))
+    except AssertionError as aserr:
+        flash("You must provide all the expected data.", "alert-danger")
+        return redirect(url_for("oauth2.data.list_data"))
+
+@data.route("/link/genotype", methods=["POST"])
+def link_genotype_data():
+    """Link genotype data to a group."""
+    form = request.form
+    link_source_url = redirect(url_for("oauth2.data.list_data"))
+    if bool(form.get("species_name")):
+        link_source_url = redirect(url_for(
+            "oauth2.data.list_data_by_species_and_dataset",
+            species_name=form["species_name"], dataset_type="genotype"))
+
+    def __link_error__(err):
+        flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+        return link_source_url
+
+    def __link_success__(success):
+        flash(success["description"], "alert-success")
+        return link_source_url
+
+    return oauth2_post("auth/data/link/genotype", json={
+        "species_name": form.get("species_name"),
+        "group_id": form.get("group_id"),
+        "selected": tuple(json.loads(dataset) for dataset
+                                   in form.getlist("selected"))
+    }).either(lambda err: __link_error__(process_error(err)), __link_success__)
+
+
+@data.route("/link/mrna", methods=["POST"])
+def link_mrna_data():
+    """Link mrna data to a group."""
+    form = request.form
+    link_source_url = redirect(url_for("oauth2.data.list_data"))
+    if bool(form.get("species_name")):
+        link_source_url = redirect(url_for(
+            "oauth2.data.list_data_by_species_and_dataset",
+            species_name=form["species_name"], dataset_type="mrna"))
+
+    def __link_error__(err):
+        error = process_error(err)
+        flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+        return link_source_url
+
+    def __link_success__(success):
+        flash(success["description"], "alert-success")
+        return link_source_url
+
+    return oauth2_post("auth/data/link/mrna", json={
+        "species_name": form.get("species_name"),
+        "group_id": form.get("group_id"),
+        "selected": tuple(json.loads(dataset) for dataset
+                                   in form.getlist("selected"))
+    }).either(lambda err: __link_error__(process_error(err)), __link_success__)
+
+@data.route("/link/phenotype", methods=["POST"])
+def link_phenotype_data():
+    """Link phenotype data to a group."""
+    form = request.form
+    link_source_url = redirect(url_for("oauth2.data.list_data"))
+    if bool(form.get("species_name")):
+        link_source_url = redirect(url_for(
+            "oauth2.data.list_data_by_species_and_dataset",
+            species_name=form["species_name"], dataset_type="phenotype"))
+
+    def __link_error__(err):
+        error = process_error(err)
+        flash(f"{error['error']}: {error['error_description']}", "alert-danger")
+        return link_source_url
+
+    def __link_success__(success):
+        flash(success["description"], "alert-success")
+        return link_source_url
+
+    return oauth2_post("auth/data/link/phenotype", json={
+        "species_name": form.get("species_name"),
+        "group_id": form.get("group_id"),
+        "selected": tuple(
+            json.loads(trait) for trait in form.getlist("selected"))}).either(
+                __link_error__, __link_success__)
diff --git a/gn2/wqflask/oauth2/groups.py b/gn2/wqflask/oauth2/groups.py
new file mode 100644
index 00000000..fd5ab7eb
--- /dev/null
+++ b/gn2/wqflask/oauth2/groups.py
@@ -0,0 +1,210 @@
+import uuid
+import datetime
+from functools import partial
+
+from flask import (
+    flash, session, request, url_for, redirect, Response, Blueprint)
+
+from .ui import render_ui
+from .checks import require_oauth2
+from .client import oauth2_get, oauth2_post
+from .request_utils import (
+    user_details, handle_error, process_error, handle_success,
+    raise_unimplemented)
+
+groups = Blueprint("group", __name__)
+
+@groups.route("/", methods=["GET"])
+def user_group():
+    """Get the user's group."""
+    def __get_join_requests__(group, users):
+        return oauth2_get("auth/group/requests/join/list").either(
+            lambda error: render_ui(
+                "oauth2/group.html", group=group, users=users,
+                group_join_requests_error=process_error(error)),
+            lambda gjr: render_ui(
+                "oauth2/group.html", group=group, users=users,
+                group_join_requests=gjr))
+    def __success__(group):
+        return oauth2_get(f"auth/group/members/{group['group_id']}").either(
+            lambda error: render_ui(
+                "oauth2/group.html", group=group,
+                user_error=process_error(error)),
+            partial(__get_join_requests__, group))
+
+    def __group_error__(err):
+        return render_ui(
+            "oauth2/group.html", group_error=process_error(err))
+
+    return oauth2_get("auth/user/group").either(
+        __group_error__, __success__)
+
+@groups.route("/create", methods=["POST"])
+@require_oauth2
+def create_group():
+    def __setup_group__(response):
+        session["user_details"]["group"] = response
+
+    resp = oauth2_post("auth/group/create", data=dict(request.form))
+    return resp.either(
+        handle_error("oauth2.group.join_or_create"),
+        handle_success(
+            "Created group", "oauth2.user.user_profile",
+            response_handlers=[__setup_group__]))
+
+@groups.route("/join-or-create", methods=["GET"])
+@require_oauth2
+def join_or_create():
+    usr_dets = user_details()
+    if bool(usr_dets["group"]):
+        flash("You are already a member of a group.", "alert-info")
+        return redirect(url_for("oauth2.user.user_profile"))
+    def __group_error__(err):
+        return render_ui(
+            "oauth2/group_join_or_create.html", groups=[],
+            groups_error=process_error(err))
+    def __group_success__(groups):
+        return oauth2_get("auth/user/group/join-request").either(
+            __gjr_error__, partial(__gjr_success__, groups=groups))
+    def __gjr_error__(err):
+        return render_ui(
+            "oauth2/group_join_or_create.html", groups=[],
+            gjr_error=process_error(err))
+    def __gjr_success__(gjr, groups):
+        return render_ui(
+            "oauth2/group_join_or_create.html", groups=groups,
+            group_join_request=gjr)
+    return oauth2_get("auth/group/list").either(
+        __group_error__, __group_success__)
+
+@groups.route("/delete/<uuid:group_id>", methods=["GET", "POST"])
+@require_oauth2
+def delete_group(group_id):
+    """Delete the user's group."""
+    return "WOULD DELETE GROUP."
+
+@groups.route("/edit/<uuid:group_id>", methods=["GET", "POST"])
+@require_oauth2
+def edit_group(group_id):
+    """Edit the user's group."""
+    return "WOULD EDIT GROUP."
+
+@groups.route("/list-join-requests", methods=["GET"])
+@require_oauth2
+def list_join_requests() -> Response:
+    def __ts_to_dt_str__(timestamp):
+        return datetime.datetime.fromtimestamp(timestamp).isoformat()
+    def __fail__(error):
+        return render_ui(
+            "oauth2/join-requests.html", error=process_error(error),
+            requests=[])
+    def __success__(requests):
+        return render_ui(
+            "oauth2/join-requests.html", error=False, requests=requests,
+            datetime_string=__ts_to_dt_str__)
+    return oauth2_get("auth/group/requests/join/list").either(
+        __fail__, __success__)
+
+@groups.route("/accept-join-requests", methods=["POST"])
+@require_oauth2
+def accept_join_request():
+    def __fail__(error):
+        err=process_error()
+        flash("{}", "alert-danger")
+        return redirect(url_for("oauth2.group.list_join_requests"))
+    def __success__(requests):
+        flash("Request was accepted successfully.", "alert-success")
+        return redirect(url_for("oauth2.group.list_join_requests"))
+    return oauth2_post(
+        "auth/group/requests/join/accept",
+        data=request.form).either(
+            handle_error("oauth2.group.list_join_requests"),
+            __success__)
+
+@groups.route("/reject-join-requests", methods=["POST"])
+@require_oauth2
+def reject_join_request():
+    def __fail__(error):
+        err=process_error()
+        flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+        return redirect(url_for("oauth2.group.list_join_requests"))
+    def __success__(requests):
+        flash("Request was rejected successfully.", "alert-success")
+        return redirect(url_for("oauth2.group.list_join_requests"))
+    return oauth2_post(
+        "auth/group/requests/join/reject",
+        data=request.form).either(
+            handle_error("oauth2.group.list_join_requests"),
+            __success__)
+
+@groups.route("/role/<uuid:group_role_id>", methods=["GET"])
+@require_oauth2
+def group_role(group_role_id: uuid.UUID):
+    """View the details of a particular role."""
+    def __render_error(**kwargs):
+        return render_ui("oauth2/view-group-role.html", **kwargs)
+
+    def __gprivs_success__(role, group_privileges):
+        return render_ui(
+            "oauth2/view-group-role.html", group_role=role,
+            group_privileges=tuple(
+                priv for priv in group_privileges
+                if priv not in role["role"]["privileges"]))
+
+    def __role_success__(role):
+        return oauth2_get("auth/group/privileges").either(
+            lambda err: __render_error__(
+                group_role=group_role,
+                group_privileges_error=process_error(err)),
+            lambda privileges: __gprivs_success__(role, privileges))
+
+    return oauth2_get(f"auth/group/role/{group_role_id}").either(
+        lambda err: __render_error__(group_role_error=process_error(err)),
+        __role_success__)
+
+def add_delete_privilege_to_role(
+        group_role_id: uuid.UUID, direction: str) -> Response:
+    """Add/delete a privilege to/from a role depending on `direction`."""
+    assert direction in ("ADD", "DELETE")
+    def __render__():
+        return redirect(url_for(
+            "oauth2.group.group_role", group_role_id=group_role_id))
+
+    def __error__(error):
+        err = process_error(error)
+        flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+        return __render__()
+
+    def __success__(success):
+        flash(success["description"], "alert-success")
+        return __render__()
+    try:
+        form = request.form
+        privilege_id = form.get("privilege_id")
+        assert bool(privilege_id), "Privilege to add must be provided"
+        uris = {
+            "ADD": f"auth/group/role/{group_role_id}/privilege/add",
+            "DELETE": f"auth/group/role/{group_role_id}/privilege/delete"
+        }
+        return oauth2_post(
+            uris[direction],
+            data={
+                "group_role_id": group_role_id,
+                "privilege_id": privilege_id
+            }).either(__error__, __success__)
+    except AssertionError as aerr:
+        flash(aerr.args[0], "alert-danger")
+        return redirect(url_for(
+            "oauth2.group.group_role", group_role_id=group_role_id))
+
+@groups.route("/role/<uuid:group_role_id>/privilege/add", methods=["POST"])
+@require_oauth2
+def add_privilege_to_role(group_role_id: uuid.UUID):
+    """Add a privilege to a group role."""
+    return add_delete_privilege_to_role(group_role_id, "ADD")
+
+@groups.route("/role/<uuid:group_role_id>/privilege/delete", methods=["POST"])
+@require_oauth2
+def delete_privilege_from_role(group_role_id: uuid.UUID):
+    """Delete a privilege from a group role."""
+    return add_delete_privilege_to_role(group_role_id, "DELETE")
diff --git a/gn2/wqflask/oauth2/request_utils.py b/gn2/wqflask/oauth2/request_utils.py
new file mode 100644
index 00000000..ade0923b
--- /dev/null
+++ b/gn2/wqflask/oauth2/request_utils.py
@@ -0,0 +1,99 @@
+"""General request utilities"""
+from typing import Optional, Callable
+from urllib.parse import urljoin, urlparse
+
+import simplejson
+from flask import (
+    flash, request, url_for, redirect, Response, render_template,
+    current_app as app)
+
+from .client import SCOPE, oauth2_get
+
+def authserver_authorise_uri():
+    from gn2.utility.tools import AUTH_SERVER_URL, OAUTH2_CLIENT_ID
+    req_baseurl = urlparse(request.base_url, scheme=request.scheme)
+    host_uri = f"{req_baseurl.scheme}://{req_baseurl.netloc}/"
+    return urljoin(
+        AUTH_SERVER_URL,
+        "auth/authorise?response_type=code"
+        f"&client_id={OAUTH2_CLIENT_ID}"
+        f"&redirect_uri={urljoin(host_uri, 'oauth2/code')}")
+
+def raise_unimplemented():
+    raise Exception("NOT IMPLEMENTED")
+
+def user_details():
+    return oauth2_get("auth/user/").either(
+        lambda err: {},
+        lambda usr_dets: usr_dets)
+
+def process_error(error: Response,
+                  message: str=("Requested endpoint was not found on the API "
+                                "server.")
+                  ) -> dict:
+    if error.status_code in range(400, 500):
+        try:
+            err = error.json()
+            msg = err.get("error_description", f"{error.reason}")
+        except simplejson.errors.JSONDecodeError as _jde:
+            msg = message
+        return {
+            "error": error.reason,
+            "error_message": msg,
+            "error_description": msg,
+            "status_code": error.status_code
+        }
+    return {**error.json(), "status_code": error.status_code}
+
+def request_error(response):
+    app.logger.error(f"{response}: {response.url} [{response.status_code}]")
+    return render_template("oauth2/request_error.html", response=response)
+
+def handle_error(redirect_uri: Optional[str] = None, **kwargs):
+    def __handler__(error):
+        error_json = process_error(error)# error.json()
+        msg = error_json.get(
+            "error_message", error_json.get(
+                "error_description", "undefined error"))
+        flash(f"{error_json['error']}: {msg}.",
+              "alert-danger")
+        if "response_handlers" in kwargs:
+            for handler in kwargs["response_handlers"]:
+                handler(response)
+        if redirect:
+            return redirect(url_for(redirect_uri, **kwargs))
+
+    return __handler__
+
+def handle_success(
+        success_msg: str, redirect_uri: Optional[str] = None, **kwargs):
+    def __handler__(response):
+        flash(f"Success: {success_msg}.", "alert-success")
+        if "response_handlers" in kwargs:
+            for handler in kwargs["response_handlers"]:
+                handler(response)
+        if redirect:
+            return redirect(url_for(redirect_uri, **kwargs))
+
+    return __handler__
+
+def flash_error(error):
+    flash(f"{error['error']}: {error['error_description']}", "alert-danger")
+
+def flash_success(success):
+    flash(f"{success['description']}", "alert-success")
+
+def with_flash_error(response) -> Callable:
+    def __err__(err) -> Response:
+        error = process_error(err)
+        flash(f"{error['status_code']} {error['error']}: "
+              f"{error['error_description']}",
+              "alert-danger")
+        return response
+    return __err__
+
+def with_flash_success(response) -> Callable:
+    def __succ__(msg) -> Response:
+        flash(f"Success: {msg['message']}", "alert-success")
+        return response
+    return __succ__
diff --git a/gn2/wqflask/oauth2/resources.py b/gn2/wqflask/oauth2/resources.py
new file mode 100644
index 00000000..7d20b859
--- /dev/null
+++ b/gn2/wqflask/oauth2/resources.py
@@ -0,0 +1,294 @@
+import uuid
+
+from flask import (
+    flash, request, jsonify, url_for, redirect, Response, Blueprint)
+
+from . import client
+from .ui import render_ui
+from .checks import require_oauth2
+from .client import oauth2_get, oauth2_post
+from .request_utils import (
+    flash_error, flash_success, request_error, process_error)
+
+resources = Blueprint("resource", __name__)
+
+@resources.route("/", methods=["GET"])
+@require_oauth2
+def user_resources():
+    """List the resources the user has access to."""
+    def __success__(resources):
+        return render_ui("oauth2/resources.html", resources=resources)
+
+    return oauth2_get("auth/user/resources").either(
+        request_error, __success__)
+
+@resources.route("/create", methods=["GET", "POST"])
+@require_oauth2
+def create_resource():
+    """Create a new resource."""
+    def __render_template__(categories=[], error=None):
+        return render_ui(
+            "oauth2/create-resource.html",
+            resource_categories=categories,
+            resource_category_error=error,
+            resource_name=request.args.get("resource_name"),
+            resource_category=request.args.get("resource_category"))
+
+    if request.method == "GET":
+        return oauth2_get("auth/resource/categories").either(
+            lambda error: __render_template__(error=process_error(
+                error, "Could not retrieve resource categories")),
+            lambda cats: __render_template__(categories=cats))
+
+    def __perr__(error):
+        err = process_error(error)
+        flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+        return redirect(url_for(
+            "oauth2.resource.create_resource",
+            resource_name=request.form.get("resource_name"),
+            resource_category=request.form.get("resource_category")))
+    def __psuc__(succ):
+        flash("Resource created successfully", "alert-success")
+        return redirect(url_for("oauth2.resource.user_resources"))
+    return oauth2_post(
+        "auth/resource/create", data=request.form).either(
+            __perr__, __psuc__)
+
+def __compute_page__(submit, current_page):
+    if submit == "next":
+        return current_page + 1
+    return (current_page - 1) or 1
+
+@resources.route("/view/<uuid:resource_id>", methods=["GET"])
+@require_oauth2
+def view_resource(resource_id: uuid.UUID):
+    """View the given resource."""
+    page = __compute_page__(request.args.get("submit"),
+                            int(request.args.get("page", "1"), base=10))
+    count_per_page = int(request.args.get("count_per_page", "100"), base=10)
+    def __users_success__(
+            resource, unlinked_data, users_n_roles, this_user, group_roles,
+            users):
+        return render_ui(
+            "oauth2/view-resource.html", resource=resource,
+            unlinked_data=unlinked_data, users_n_roles=users_n_roles,
+            this_user=this_user, group_roles=group_roles, users=users,
+            page=page, count_per_page=count_per_page)
+
+    def __group_roles_success__(
+            resource, unlinked_data, users_n_roles, this_user, group_roles):
+        return oauth2_get("auth/user/list").either(
+            lambda err: render_ui(
+                "oauth2/view-resource.html", resource=resource,
+                unlinked_data=unlinked_data, users_n_roles=users_n_roles,
+                this_user=this_user, group_roles=group_roles,
+                users_error=process_error(err)),
+            lambda users: __users_success__(
+                resource, unlinked_data, users_n_roles, this_user, group_roles,
+                users))
+
+    def __this_user_success__(resource, unlinked_data, users_n_roles, this_user):
+        return oauth2_get("auth/group/roles").either(
+            lambda err: render_ui(
+                "oauth2/view-resources.html", resource=resource,
+                unlinked_data=unlinked_data, users_n_roles=users_n_roles,
+                this_user=this_user, group_roles_error=process_error(err)),
+            lambda groles: __group_roles_success__(
+                resource, unlinked_data, users_n_roles, this_user, groles))
+
+    def __users_n_roles_success__(resource, unlinked_data, users_n_roles):
+        return oauth2_get("auth/user/").either(
+            lambda err: render_ui(
+                "oauth2/view-resources.html",
+                this_user_error=process_error(err)),
+            lambda usr_dets: __this_user_success__(
+                resource, unlinked_data, users_n_roles, usr_dets))
+
+    def __unlinked_success__(resource, unlinked_data):
+        return oauth2_get(f"auth/resource/{resource_id}/user/list").either(
+            lambda err: render_ui(
+                "oauth2/view-resource.html",
+                resource=resource,
+                unlinked_data=unlinked_data,
+                users_n_roles_error=process_error(err),
+                page=page,
+                count_per_page=count_per_page),
+            lambda users_n_roles: __users_n_roles_success__(
+                resource, unlinked_data, users_n_roles))
+
+    def __resource_success__(resource):
+        dataset_type = resource["resource_category"]["resource_category_key"]
+        return oauth2_get(f"auth/group/{dataset_type}/unlinked-data").either(
+            lambda err: render_ui(
+                "oauth2/view-resource.html", resource=resource,
+                unlinked_error=process_error(err)),
+            lambda unlinked: __unlinked_success__(resource, unlinked))
+
+    def __fetch_resource_data__(resource):
+        """Fetch the resource's data."""
+        return client.get(
+            f"auth/resource/view/{resource['resource_id']}/data?page={page}"
+            f"&count_per_page={count_per_page}").either(
+                lambda err: {
+                    **resource, "resource_data_error": process_error(err)
+                },
+                lambda resdata: {**resource, "resource_data": resdata})
+
+    return oauth2_get(f"auth/resource/view/{resource_id}").map(
+        __fetch_resource_data__).either(
+            lambda err: render_ui(
+                "oauth2/view-resource.html",
+                resource=None, resource_error=process_error(err)),
+            __resource_success__)
+
+@resources.route("/data/link", methods=["POST"])
+@require_oauth2
+def link_data_to_resource():
+    """Link group data to a resource"""
+    form = request.form
+    try:
+        assert "resource_id" in form, "Resource ID not provided."
+        assert "data_link_id" in form, "Data Link ID not provided."
+        assert "dataset_type" in form, "Dataset type not specified"
+        assert form["dataset_type"].lower() in (
+            "mrna", "genotype", "phenotype"), "Invalid dataset type provided."
+        resource_id = form["resource_id"]
+
+        def __error__(error):
+            err = process_error(error)
+            flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+            return redirect(url_for(
+                "oauth2.resource.view_resource", resource_id=resource_id))
+
+        def __success__(success):
+            flash(f"Data linked to resource successfully", "alert-success")
+            return redirect(url_for(
+                "oauth2.resource.view_resource", resource_id=resource_id))
+        return oauth2_post("auth/resource/data/link", data=dict(form)).either(
+            __error__,
+            __success__)
+    except AssertionError as aserr:
+        flash(aserr.args[0], "alert-danger")
+        return redirect(url_for(
+            "oauth2.resource.view_resource", resource_id=form["resource_id"]))
+
+@resources.route("/data/unlink", methods=["POST"])
+@require_oauth2
+def unlink_data_from_resource():
+    """Unlink group data from a resource"""
+    form = request.form
+    try:
+        assert "resource_id" in form, "Resource ID not provided."
+        assert "data_link_id" in form, "Data Link ID not provided."
+        resource_id = form["resource_id"]
+
+        def __error__(error):
+            err = process_error(error)
+            flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+            return redirect(url_for(
+                "oauth2.resource.view_resource", resource_id=resource_id))
+
+        def __success__(success):
+            flash(f"Data unlinked from resource successfully", "alert-success")
+            return redirect(url_for(
+                "oauth2.resource.view_resource", resource_id=resource_id))
+        return oauth2_post(
+            "auth/resource/data/unlink", data=dict(form)).either(
+            __error__, __success__)
+    except AssertionError as aserr:
+        flash(aserr.args[0], "alert-danger")
+        return redirect(url_for(
+            "oauth2.resource.view_resource", resource_id=form["resource_id"]))
+
+@resources.route("<uuid:resource_id>/user/assign", methods=["POST"])
+@require_oauth2
+def assign_role(resource_id: uuid.UUID) -> Response:
+    form = request.form
+    group_role_id = form.get("group_role_id", "")
+    user_email = form.get("user_email", "")
+    try:
+        assert bool(group_role_id), "The role must be provided."
+        assert bool(user_email), "The user email must be provided."
+
+        def __assign_error__(error):
+            err = process_error(error)
+            flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+            return redirect(url_for(
+                "oauth2.resource.view_resource", resource_id=resource_id))
+
+        def __assign_success__(success):
+            flash(success["description"], "alert-success")
+            return redirect(url_for(
+                "oauth2.resource.view_resource", resource_id=resource_id))
+
+        return oauth2_post(
+            f"auth/resource/{resource_id}/user/assign",
+            data={
+                "group_role_id": group_role_id,
+                "user_email": user_email
+            }).either(__assign_error__, __assign_success__)
+    except AssertionError as aserr:
+        flash(aserr.args[0], "alert-danger")
+        return redirect(url_for("oauth2.resources.view_resource", resource_id=resource_id))
+
+@resources.route("<uuid:resource_id>/user/unassign", methods=["POST"])
+@require_oauth2
+def unassign_role(resource_id: uuid.UUID) -> Response:
+    form = request.form
+    group_role_id = form.get("group_role_id", "")
+    user_id = form.get("user_id", "")
+    try:
+        assert bool(group_role_id), "The role must be provided."
+        assert bool(user_id), "The user id must be provided."
+
+        def __unassign_error__(error):
+            err = process_error(error)
+            flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+            return redirect(url_for(
+                "oauth2.resource.view_resource", resource_id=resource_id))
+
+        def __unassign_success__(success):
+            flash(success["description"], "alert-success")
+            return redirect(url_for(
+                "oauth2.resource.view_resource", resource_id=resource_id))
+
+        return oauth2_post(
+            f"auth/resource/{resource_id}/user/unassign",
+            data={
+                "group_role_id": group_role_id,
+                "user_id": user_id
+            }).either(__unassign_error__, __unassign_success__)
+    except AssertionError as aserr:
+        flash(aserr.args[0], "alert-danger")
+        return redirect(url_for("oauth2.resources.view_resource", resource_id=resource_id))
+
+@resources.route("/toggle/<uuid:resource_id>", methods=["POST"])
+@require_oauth2
+def toggle_public(resource_id: uuid.UUID):
+    """Toggle the given resource's public status."""
+    def __handle_error__(err):
+        flash_error(process_error(err))
+        return redirect(url_for(
+            "oauth2.resource.view_resource", resource_id=resource_id))
+
+    def __handle_success__(success):
+        flash_success(success)
+        return redirect(url_for(
+            "oauth2.resource.view_resource", resource_id=resource_id))
+
+    return oauth2_post(
+        f"auth/resource/{resource_id}/toggle-public", data={}).either(
+            lambda err: __handle_error__(err),
+            lambda suc: __handle_success__(suc))
+
+@resources.route("/edit/<uuid:resource_id>", methods=["GET"])
+@require_oauth2
+def edit_resource(resource_id: uuid.UUID):
+    """Edit the given resource."""
+    return "WOULD Edit THE GIVEN RESOURCE'S DETAILS"
+
+@resources.route("/delete/<uuid:resource_id>", methods=["GET"])
+@require_oauth2
+def delete_resource(resource_id: uuid.UUID):
+    """Delete the given resource."""
+    return "WOULD DELETE THE GIVEN RESOURCE"
diff --git a/gn2/wqflask/oauth2/roles.py b/gn2/wqflask/oauth2/roles.py
new file mode 100644
index 00000000..2fe35f9b
--- /dev/null
+++ b/gn2/wqflask/oauth2/roles.py
@@ -0,0 +1,99 @@
+"""Handle role endpoints"""
+import uuid
+
+from flask import flash, request, url_for, redirect, Blueprint
+
+from .ui import render_ui
+from .checks import require_oauth2
+from .client import oauth2_get, oauth2_post
+from .request_utils import request_error, process_error
+
+roles = Blueprint("role", __name__)
+
+@roles.route("/user", methods=["GET"])
+@require_oauth2
+def user_roles():
+    def  __grerror__(roles, user_privileges, error):
+        return render_ui(
+            "oauth2/list_roles.html", roles=roles,
+            user_privileges=user_privileges,
+            group_roles_error=process_error(error))
+
+    def  __grsuccess__(roles, user_privileges, group_roles):
+        return render_ui(
+            "oauth2/list_roles.html", roles=roles,
+            user_privileges=user_privileges, group_roles=group_roles)
+
+    def __role_success__(roles):
+        uprivs = tuple(
+            privilege["privilege_id"] for role in roles
+            for privilege in role["privileges"])
+        return oauth2_get("auth/group/roles").either(
+            lambda err: __grerror__(roles, uprivs, err),
+            lambda groles: __grsuccess__(roles, uprivs, groles))
+
+    return oauth2_get("auth/system/roles").either(
+        request_error, __role_success__)
+
+@roles.route("/role/<uuid:role_id>", methods=["GET"])
+@require_oauth2
+def role(role_id: uuid.UUID):
+    def __success__(the_role):
+        return render_ui("oauth2/role.html",
+                         role=the_role[0],
+                         resource_id=uuid.UUID(the_role[1]))
+
+    return oauth2_get(f"auth/role/view/{role_id}").either(
+        request_error, __success__)
+
+@roles.route("/create", methods=["GET", "POST"])
+@require_oauth2
+def create_role():
+    """Create a new role."""
+    def __roles_error__(error):
+        return render_ui(
+            "oauth2/create-role.html", roles_error=process_error(error))
+
+    def __gprivs_error__(roles, error):
+        return render_ui(
+            "oauth2/create-role.html", roles=roles,
+            group_privileges_error=process_error(error))
+
+    def __success__(roles, gprivs):
+        uprivs = tuple(
+            privilege["privilege_id"] for role in roles
+            for privilege in role["privileges"])
+        return render_ui(
+            "oauth2/create-role.html", roles=roles, user_privileges=uprivs,
+            group_privileges=gprivs,
+            prev_role_name=request.args.get("role_name"))
+
+    def __fetch_gprivs__(roles):
+        return oauth2_get("auth/group/privileges").either(
+            lambda err: __gprivs_error__(roles, err),
+            lambda gprivs: __success__(roles, gprivs))
+
+    if request.method == "GET":
+        return oauth2_get("auth/user/roles").either(
+            __roles_error__, __fetch_gprivs__)
+
+    form = request.form
+    role_name = form.get("role_name")
+    privileges = form.getlist("privileges[]")
+    if len(privileges) == 0:
+        flash("You must assign at least one privilege to the role",
+              "alert-danger")
+        return redirect(url_for(
+            "oauth2.role.create_role", role_name=role_name))
+    def __create_error__(error):
+        err = process_error(error)
+        flash(f"{err['error']}: {err['error_description']}",
+              "alert-danger")
+        return redirect(url_for("oauth2.role.create_role"))
+    def __create_success__(*args):
+        flash("Role created successfully.", "alert-success")
+        return redirect(url_for("oauth2.role.user_roles"))
+    return oauth2_post(
+        "auth/group/role/create",data={
+            "role_name": role_name, "privileges[]": privileges}).either(
+        __create_error__,__create_success__)
diff --git a/gn2/wqflask/oauth2/routes.py b/gn2/wqflask/oauth2/routes.py
new file mode 100644
index 00000000..4c4b877b
--- /dev/null
+++ b/gn2/wqflask/oauth2/routes.py
@@ -0,0 +1,18 @@
+"""Routes for the OAuth2 auth system in GN3"""
+from flask import Blueprint
+
+from .data import data
+from .users import users
+from .roles import roles
+from .groups import groups
+from .toplevel import toplevel
+from .resources import resources
+
+oauth2 = Blueprint("oauth2", __name__, template_folder="templates/oauth2")
+
+oauth2.register_blueprint(toplevel, url_prefix="/")
+oauth2.register_blueprint(data, url_prefix="/data")
+oauth2.register_blueprint(users, url_prefix="/user")
+oauth2.register_blueprint(roles, url_prefix="/role")
+oauth2.register_blueprint(groups, url_prefix="/group")
+oauth2.register_blueprint(resources, url_prefix="/resource")
diff --git a/gn2/wqflask/oauth2/session.py b/gn2/wqflask/oauth2/session.py
new file mode 100644
index 00000000..2ef534e2
--- /dev/null
+++ b/gn2/wqflask/oauth2/session.py
@@ -0,0 +1,111 @@
+"""Deal with user sessions"""
+from uuid import UUID, uuid4
+from datetime import datetime
+from typing import Any, Optional, TypedDict
+
+from flask import request, session
+from pymonad.either import Left, Right, Either
+
+class UserDetails(TypedDict):
+    """Session information relating specifically to the user."""
+    user_id: UUID
+    name: str
+    email: str
+    token: Either
+    logged_in: bool
+
+class SessionInfo(TypedDict):
+    """All Session information we save."""
+    session_id: UUID
+    user: UserDetails
+    anon_id: UUID
+    user_agent: str
+    ip_addr: str
+    masquerade: Optional[UserDetails]
+
+__SESSION_KEY__ = "GN::2::session_info" # Do not use this outside this module!!
+
+def clear_session_info():
+    """Clears the session."""
+    session.pop(__SESSION_KEY__)
+
+def save_session_info(sess_info: SessionInfo) -> SessionInfo:
+    """Save `session_info`."""
+    # TODO: if it is an existing session, verify that certain important security
+    #       bits have not changed before saving.
+    # old_session_info = session.get(__SESSION_KEY__)
+    # if bool(old_session_info):
+    #     if old_session_info["user_agent"] == request.headers.get("User-Agent"):
+    #         session[__SESSION_KEY__] = sess_info
+    #         return sess_info
+    #     # request session verification
+    #     return verify_session(sess_info)
+    # New session
+    session[__SESSION_KEY__] = sess_info
+    return sess_info
+
+def session_info() -> SessionInfo:
+    """Retrieve the session information"""
+    anon_id = uuid4()
+    return save_session_info(
+        session.get(__SESSION_KEY__, {
+            "session_id": uuid4(),
+            "user": {
+                "user_id": anon_id,
+                "name": "Anonymous User",
+                "email": "anon@ymous.user",
+                "token": Left("INVALID-TOKEN"),
+                "logged_in": False
+            },
+            "anon_id": anon_id,
+            "user_agent": request.headers.get("User-Agent"),
+            "ip_addr": request.environ.get("HTTP_X_FORWARDED_FOR",
+                                           request.remote_addr),
+            "masquerading": None
+        }))
+
+def expired():
+    the_session = session_info()
+    def __expired__(token):
+        return datetime.now() > datetime.fromtimestamp(token["expires_at"])
+    return the_session["user"]["token"].either(
+        lambda left: False,
+        __expired__)
+
+def set_user_token(token: str) -> SessionInfo:
+    """Set the user's token."""
+    info = session_info()
+    return save_session_info({
+        **info, "user": {**info["user"], "token": Right(token)}})
+
+def set_user_details(userdets: UserDetails) -> SessionInfo:
+    """Set the user details information"""
+    return save_session_info({**session_info(), "user": userdets})
+
+def user_token() -> Either:
+    """Retrieve the user token."""
+    return session_info()["user"]["token"]
+
+def set_masquerading(masq_info):
+    """Save the masquerading user information."""
+    orig_user = session_info()["user"]
+    return save_session_info({
+        **session_info(),
+        "user": {
+            "user_id": UUID(masq_info["masquerade_as"]["user"]["user_id"]),
+            "name": masq_info["masquerade_as"]["user"]["name"],
+            "email": masq_info["masquerade_as"]["user"]["email"],
+            "token": Right(masq_info["masquerade_as"]["token"]),
+            "logged_in": True
+        },
+        "masquerading": orig_user
+    })
+
+def unset_masquerading():
+    """Restore the original session."""
+    the_session = session_info()
+    return save_session_info({
+        **the_session,
+        "user": the_session["masquerading"],
+        "masquerading": None
+    })
diff --git a/gn2/wqflask/oauth2/toplevel.py b/gn2/wqflask/oauth2/toplevel.py
new file mode 100644
index 00000000..65f60067
--- /dev/null
+++ b/gn2/wqflask/oauth2/toplevel.py
@@ -0,0 +1,57 @@
+"""Authentication endpoints."""
+from uuid import UUID
+from urllib.parse import urljoin, urlparse, urlunparse
+from flask import (
+    flash, request, Blueprint, url_for, redirect, render_template,
+    current_app as app)
+
+from . import session
+from .client import SCOPE, no_token_post
+from .checks import require_oauth2, user_logged_in
+from .request_utils import user_details, process_error
+
+toplevel = Blueprint("toplevel", __name__)
+
+@toplevel.route("/register-client", methods=["GET", "POST"])
+@require_oauth2
+def register_client():
+    """Register an OAuth2 client."""
+    return "USER IS LOGGED IN AND SUCCESSFULLY ACCESSED THIS ENDPOINT!"
+
+@toplevel.route("/code", methods=["GET"])
+def authorisation_code():
+    """Use authorisation code to get token."""
+    def __error__(error):
+        flash(f"{error['error']}: {error['error_description']}",
+              "alert-danger")
+        return redirect("/")
+
+    def __success__(token):
+        session.set_user_token(token)
+        udets = user_details()
+        session.set_user_details({
+            "user_id": UUID(udets["user_id"]),
+            "name": udets["name"],
+            "email": udets["email"],
+            "token": session.user_token(),
+            "logged_in": True
+        })
+        return redirect("/")
+
+    code = request.args.get("code", "")
+    if bool(code):
+        base_url = urlparse(request.base_url, scheme=request.scheme)
+        request_data = {
+            "grant_type": "authorization_code",
+            "code": code,
+            "scope": SCOPE,
+            "redirect_uri": urljoin(
+                urlunparse(base_url),
+                url_for("oauth2.toplevel.authorisation_code")),
+            "client_id": app.config["OAUTH2_CLIENT_ID"]
+        }
+        return no_token_post(
+            "auth/token", data=request_data).either(
+                lambda err: __error__(process_error(err)), __success__)
+    flash("AuthorisationError: No code was provided.", "alert-danger")
+    return redirect("/")
diff --git a/gn2/wqflask/oauth2/ui.py b/gn2/wqflask/oauth2/ui.py
new file mode 100644
index 00000000..b706cdd9
--- /dev/null
+++ b/gn2/wqflask/oauth2/ui.py
@@ -0,0 +1,23 @@
+"""UI utilities"""
+from flask import session, render_template
+
+from .client import oauth2_get
+from .checks import user_logged_in
+from .request_utils import process_error
+
+def render_ui(templatepath: str, **kwargs):
+    """Handle repetitive UI rendering stuff."""
+    roles = kwargs.get("roles", tuple()) # Get roles if already provided
+    if user_logged_in() and not bool(roles): # If not, try fetching them
+        roles_results = oauth2_get("auth/system/roles").either(
+            lambda err: {"roles_error": process_error(err)},
+            lambda roles: {"roles": roles})
+        kwargs = {**kwargs, **roles_results}
+        roles = kwargs.get("roles", tuple())
+    user_privileges = tuple(
+        privilege["privilege_id"] for role in roles
+        for privilege in role["privileges"])
+    kwargs = {
+        **kwargs, "roles": roles, "user_privileges": user_privileges
+    }
+    return render_template(templatepath, **kwargs)
diff --git a/gn2/wqflask/oauth2/users.py b/gn2/wqflask/oauth2/users.py
new file mode 100644
index 00000000..12d07d0c
--- /dev/null
+++ b/gn2/wqflask/oauth2/users.py
@@ -0,0 +1,190 @@
+import requests
+from uuid import UUID
+from urllib.parse import urljoin
+
+from authlib.integrations.base_client.errors import OAuthError
+from flask import (
+    flash, request, url_for, redirect, Response, Blueprint,
+    current_app as app)
+
+from . import client
+from . import session
+from .ui import render_ui
+from .checks import require_oauth2, user_logged_in
+from .client import oauth2_get, oauth2_post, oauth2_client
+from .request_utils import (
+    user_details, request_error, process_error, with_flash_error)
+
+users = Blueprint("user", __name__)
+
+@users.route("/profile", methods=["GET"])
+@require_oauth2
+def user_profile():
+    __id__ = lambda the_val: the_val
+    usr_dets = user_details()
+    def __render__(usr_dets, roles=[], **kwargs):
+        return render_ui(
+            "oauth2/view-user.html", user_details=usr_dets, roles=roles,
+            user_privileges = tuple(
+                privilege["privilege_id"] for role in roles
+                for privilege in role["privileges"]),
+            **kwargs)
+
+    def __roles_success__(roles):
+        if bool(usr_dets.get("group")):
+            return __render__(usr_dets, roles)
+        return oauth2_get("auth/user/group/join-request").either(
+            lambda err: __render__(
+                user_details, group_join_error=process_error(err)),
+            lambda gjr: __render__(usr_dets, roles=roles, group_join_request=gjr))
+
+    return oauth2_get("auth/system/roles").either(
+        lambda err: __render__(usr_dets, role_error=process_error(err)),
+        __roles_success__)
+
+@users.route("/request-add-to-group", methods=["POST"])
+@require_oauth2
+def request_add_to_group() -> Response:
+    """Request to be added to a group."""
+    form = request.form
+    group_id = form["group"]
+
+    def __error__(error):
+        err = process_error(error)
+        flash(f"{err['error']}: {err['error_message']}", "alert-danger")
+        return redirect(url_for("oauth2.user.user_profile"))
+
+    def __success__(response):
+        flash(f"{response['message']} (Response ID: {response['request_id']})",
+              "alert-success")
+        return redirect(url_for("oauth2.user.user_profile"))
+
+    return oauth2_post(f"auth/group/requests/join/{group_id}",
+                       data=form).either(__error__, __success__)
+
+@users.route("/login", methods=["GET", "POST"])
+def login():
+    """Route to allow users to sign up."""
+    from gn2.utility.tools import AUTH_SERVER_URL
+    next_endpoint=request.args.get("next", False)
+
+    if request.method == "POST":
+        form = request.form
+        client = oauth2_client()
+        try:
+            token = client.fetch_token(
+                urljoin(AUTH_SERVER_URL, "auth/token"),
+                username=form.get("email_address"),
+                password=form.get("password"),
+                grant_type="password")
+            session.set_user_token(token)
+            udets = user_details()
+            session.set_user_details({
+                "user_id": UUID(udets["user_id"]),
+                "name": udets["name"],
+                "email": udets["email"],
+                "token": session.user_token(),
+                "logged_in": True
+            })
+        except OAuthError as _oaerr:
+            flash(_oaerr.args[0], "alert-danger")
+            return render_ui(
+                "oauth2/login.html", next_endpoint=next_endpoint,
+                email=form.get("email_address"))
+
+    if user_logged_in():
+        if next_endpoint:
+            return redirect(url_for(next_endpoint))
+        return redirect("/")
+
+    return render_ui("oauth2/login.html", next_endpoint=next_endpoint)
+
+@users.route("/logout", methods=["GET", "POST"])
+def logout():
+    from gn2.utility.tools import AUTH_SERVER_URL
+    if user_logged_in():
+        resp = oauth2_client().revoke_token(
+            urljoin(AUTH_SERVER_URL, "auth/revoke"))
+        the_session = session.session_info()
+        if not bool(the_session["masquerading"]):
+            # Normal session - clear and go back.
+            session.clear_session_info()
+            flash("Successfully logged out.", "alert-success")
+            return redirect("/")
+        # Restore masquerading session
+        session.unset_masquerading()
+        flash(
+            "Successfully logged out as user "
+            f"{the_session['user']['name']} ({the_session['user']['email']}) "
+            "and restored session for user "
+            f"{the_session['masquerading']['name']} "
+            f"({the_session['masquerading']['email']})",
+            "alert-success")
+        return redirect("/")
+
+@users.route("/register", methods=["GET", "POST"])
+def register_user():
+    from gn2.utility.tools import AUTH_SERVER_URL
+    if user_logged_in():
+        next_endpoint=request.args.get("next", "/")
+        flash(("You cannot register a new user while logged in. "
+               "Please logout to register a new user."),
+              "alert-danger")
+        return redirect(next_endpoint)
+
+    if request.method == "GET":
+        return render_ui("oauth2/register_user.html")
+
+    form = request.form
+    response = requests.post(
+        urljoin(AUTH_SERVER_URL, "auth/user/register"),
+        data = {
+            "user_name": form.get("user_name"),
+            "email": form.get("email_address"),
+            "password": form.get("password"),
+            "confirm_password": form.get("confirm_password")})
+    results = response.json()
+    if "error" in results:
+        error_messages = tuple(
+            f"{results['error']}: {msg.strip()}"
+            for msg in results.get("error_description").split("::"))
+        for message in error_messages:
+            flash(message, "alert-danger")
+        return redirect(url_for("oauth2.user.register_user"))
+
+    flash("Registration successful! Please login to continue.", "alert-success")
+    return redirect(url_for("oauth2.user.login"))
+
+@users.route("/masquerade", methods=["GET", "POST"])
+def masquerade():
+    """Masquerade as a particular user."""
+    if request.method == "GET":
+        this_user = session.session_info()["user"]
+        return client.get("auth/user/list").either(
+            lambda err: render_ui(
+                "oauth2/masquerade.html", users_error=process_error(err)),
+            lambda usrs: render_ui(
+                "oauth2/masquerade.html", users=tuple(
+                    usr for usr in usrs
+                    if UUID(usr["user_id"]) != this_user["user_id"])))
+
+    def __masq_success__(masq_details):
+        session.set_masquerading(masq_details)
+        flash(
+            f"User {masq_details['original']['user']['name']} "
+            f"({masq_details['original']['user']['email']}) is now "
+            "successfully masquerading as the user "
+            f"User {masq_details['masquerade_as']['user']['name']} "
+            f"({masq_details['masquerade_as']['user']['email']}) is now ",
+            "alert-success")
+        return redirect("/")
+    form = request.form
+    masquerade_as = form.get("masquerade_as").strip()
+    if not(bool(masquerade_as)):
+        flash("You must provide a user to masquerade as.", "alert-danger")
+        return redirect(url_for("oauth2.user.masquerade"))
+    return client.post(
+        "auth/user/masquerade/",
+        json={"masquerade_as": request.form.get("masquerade_as")}).either(
+            with_flash_error(redirect(url_for("oauth2.user.masquerade"))),
+            __masq_success__)