about summary refs log tree commit diff
path: root/gn_auth/auth/authentication/oauth2
diff options
context:
space:
mode:
authorFrederick Muriuki Muriithi2023-08-04 10:10:28 +0300
committerFrederick Muriuki Muriithi2023-08-04 10:20:09 +0300
commit8b7c598407a5fea9a3d78473e72df87606998cd4 (patch)
tree8526433a17eca6b511feb082a0574f9b15cb9469 /gn_auth/auth/authentication/oauth2
parentf7fcbbcc014686ac597b783a8dcb38b43024b9d6 (diff)
downloadgn-auth-8b7c598407a5fea9a3d78473e72df87606998cd4.tar.gz
Copy over files from GN3 repository.
Diffstat (limited to 'gn_auth/auth/authentication/oauth2')
-rw-r--r--gn_auth/auth/authentication/oauth2/__init__.py0
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/__init__.py0
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/introspection.py48
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/revocation.py22
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/utilities.py30
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/__init__.py0
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/authorisation_code_grant.py85
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/password_grant.py22
-rw-r--r--gn_auth/auth/authentication/oauth2/models/__init__.py0
-rw-r--r--gn_auth/auth/authentication/oauth2/models/authorization_code.py93
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2client.py234
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2token.py132
-rw-r--r--gn_auth/auth/authentication/oauth2/resource_server.py19
-rw-r--r--gn_auth/auth/authentication/oauth2/server.py72
-rw-r--r--gn_auth/auth/authentication/oauth2/views.py104
15 files changed, 861 insertions, 0 deletions
diff --git a/gn_auth/auth/authentication/oauth2/__init__.py b/gn_auth/auth/authentication/oauth2/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/__init__.py
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/__init__.py b/gn_auth/auth/authentication/oauth2/endpoints/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/endpoints/__init__.py
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
new file mode 100644
index 0000000..a567363
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
@@ -0,0 +1,48 @@
+"""Handle introspection of tokens."""
+import datetime
+from urllib.parse import urlparse
+
+from flask import request as flask_request
+from authlib.oauth2.rfc7662 import (
+    IntrospectionEndpoint as _IntrospectionEndpoint)
+
+from gn3.auth.authentication.oauth2.models.oauth2token import OAuth2Token
+
+from .utilities import query_token as _query_token
+
+def get_token_user_sub(token: OAuth2Token) -> str:# pylint: disable=[unused-argument]
+    """
+    Return the token's subject as defined in
+    https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2
+    """
+    ## For now a dummy return to prevent issues.
+    return "sub"
+
+class IntrospectionEndpoint(_IntrospectionEndpoint):
+    """Introspect token."""
+    def query_token(self, token_string: str, token_type_hint: str):
+        """Query the token."""
+        return _query_token(self, token_string, token_type_hint)
+
+    def introspect_token(self, token: OAuth2Token) -> dict:# pylint: disable=[no-self-use]
+        """Return the introspection information."""
+        url = urlparse(flask_request.url)
+        return {
+            "active": True,
+            "scope": token.get_scope(),
+            "client_id": token.client.client_id,
+            "username": token.user.name,
+            "token_type": token.token_type,
+            "exp": int(token.expires_at.timestamp()),
+            "iat": int(token.issued_at.timestamp()),
+            "nbf": int(
+                (token.issued_at - datetime.timedelta(seconds=120)).timestamp()),
+            # "sub": get_token_user_sub(token),
+            "aud": token.client.client_id,
+            "iss": f"{url.scheme}://{url.netloc}",
+            "jti": token.token_id
+        }
+
+    def check_permission(self, token, client, request):# pylint: disable=[unused-argument, no-self-use]
+        """Check that the client has permission to introspect token."""
+        return client.client_type == "internal"
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
new file mode 100644
index 0000000..b8517b6
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
@@ -0,0 +1,22 @@
+"""Handle token revocation."""
+
+from flask import current_app
+from authlib.oauth2.rfc7009 import RevocationEndpoint as _RevocationEndpoint
+
+from gn3.auth import db
+from gn3.auth.authentication.oauth2.models.oauth2token import (
+    save_token, OAuth2Token, revoke_token)
+
+from .utilities import query_token as _query_token
+
+class RevocationEndpoint(_RevocationEndpoint):
+    """Revoke the tokens"""
+    ENDPOINT_NAME = "revoke"
+    def query_token(self, token_string: str, token_type_hint: str):
+        """Query the token."""
+        return _query_token(self, token_string, token_type_hint)
+
+    def revoke_token(self, token: OAuth2Token, request):
+        """Revoke token `token`."""
+        with db.connection(current_app.config["AUTH_DB"]) as conn:
+            save_token(conn, revoke_token(token))
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/utilities.py b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
new file mode 100644
index 0000000..299f151
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
@@ -0,0 +1,30 @@
+"""endpoint utilities"""
+from typing import Any, Optional
+
+from flask import current_app
+from pymonad.maybe import Nothing
+
+from gn3.auth import db
+from gn3.auth.authentication.oauth2.models.oauth2token import (
+    OAuth2Token, token_by_access_token, token_by_refresh_token)
+
+def query_token(# pylint: disable=[unused-argument]
+        endpoint_object: Any, token_str: str, token_type_hint) -> Optional[
+            OAuth2Token]:
+    """Retrieve the token from the database."""
+    __identity__ = lambda val: val
+    token = Nothing
+    with db.connection(current_app.config["AUTH_DB"]) as conn:
+        if token_type_hint == "access_token":
+            token = token_by_access_token(conn, token_str)
+        if token_type_hint == "access_token":
+            token = token_by_refresh_token(conn, token_str)
+
+        return token.maybe(
+            token_by_access_token(conn, token_str).maybe(
+                token_by_refresh_token(conn, token_str).maybe(
+                    None, __identity__),
+                __identity__),
+            __identity__)
+
+    return None
diff --git a/gn_auth/auth/authentication/oauth2/grants/__init__.py b/gn_auth/auth/authentication/oauth2/grants/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/grants/__init__.py
diff --git a/gn_auth/auth/authentication/oauth2/grants/authorisation_code_grant.py b/gn_auth/auth/authentication/oauth2/grants/authorisation_code_grant.py
new file mode 100644
index 0000000..f80d02e
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/grants/authorisation_code_grant.py
@@ -0,0 +1,85 @@
+"""Classes and function for Authorisation Code flow."""
+import uuid
+import string
+import random
+from typing import Optional
+from datetime import datetime
+
+from flask import current_app as app
+from authlib.oauth2.rfc6749 import grants
+from authlib.oauth2.rfc7636 import create_s256_code_challenge
+
+from gn3.auth import db
+from gn3.auth.db_utils import with_db_connection
+from gn3.auth.authentication.users import User
+
+from ..models.oauth2client import OAuth2Client
+from ..models.authorization_code import (
+    AuthorisationCode, authorisation_code, save_authorisation_code)
+
+class AuthorisationCodeGrant(grants.AuthorizationCodeGrant):
+    """Implement the 'Authorisation Code' grant."""
+    TOKEN_ENDPOINT_AUTH_METHODS: list[str] = [
+        "client_secret_basic", "client_secret_post"]
+    AUTHORIZATION_CODE_LENGTH: int = 48
+    TOKEN_ENDPOINT_HTTP_METHODS = ['POST']
+    GRANT_TYPE = "authorization_code"
+    RESPONSE_TYPES = {'code'}
+
+    def save_authorization_code(self, code, request):
+        """Persist the authorisation code to database."""
+        client = request.client
+        nonce = "".join(random.sample(string.ascii_letters + string.digits,
+                                      k=self.AUTHORIZATION_CODE_LENGTH))
+        return __save_authorization_code__(AuthorisationCode(
+            uuid.uuid4(), code, client, request.redirect_uri, request.scope,
+            nonce, int(datetime.now().timestamp()),
+            create_s256_code_challenge(app.config["SECRET_KEY"]),
+            "S256", request.user))
+
+    def query_authorization_code(self, code, client):
+        """Retrieve the code from the database."""
+        return __query_authorization_code__(code, client)
+
+    def delete_authorization_code(self, authorization_code):# pylint: disable=[no-self-use]
+        """Delete the authorisation code."""
+        with db.connection(app.config["AUTH_DB"]) as conn:
+            with db.cursor(conn) as cursor:
+                cursor.execute(
+                    "DELETE FROM authorisation_code WHERE code_id=?",
+                    (str(authorization_code.code_id),))
+
+    def authenticate_user(self, authorization_code) -> Optional[User]:
+        """Authenticate the user who own the authorisation code."""
+        query = (
+            "SELECT users.* FROM authorisation_code LEFT JOIN users "
+            "ON authorisation_code.user_id=users.user_id "
+            "WHERE authorisation_code.code=?")
+        with db.connection(app.config["AUTH_DB"]) as conn:
+            with db.cursor(conn) as cursor:
+                cursor.execute(query, (str(authorization_code.code),))
+                res = cursor.fetchone()
+                if res:
+                    return User(
+                        uuid.UUID(res["user_id"]), res["email"], res["name"])
+
+        return None
+
+def __query_authorization_code__(
+        code: str, client: OAuth2Client) -> AuthorisationCode:
+    """A helper function that creates a new database connection.
+
+    This is found to be necessary since the `AuthorizationCodeGrant` class(es)
+    do not have a way to pass the database connection."""
+    def __auth_code__(conn) -> str:
+        the_code = authorisation_code(conn, code, client)
+        return the_code.maybe(None, lambda cde: cde) # type: ignore[misc, arg-type, return-value]
+
+    return with_db_connection(__auth_code__)
+
+def __save_authorization_code__(code: AuthorisationCode) -> AuthorisationCode:
+    """A helper function that creates a new database connection.
+
+    This is found to be necessary since the `AuthorizationCodeGrant` class(es)
+    do not have a way to pass the database connection."""
+    return with_db_connection(lambda conn: save_authorisation_code(conn, code))
diff --git a/gn_auth/auth/authentication/oauth2/grants/password_grant.py b/gn_auth/auth/authentication/oauth2/grants/password_grant.py
new file mode 100644
index 0000000..3233877
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/grants/password_grant.py
@@ -0,0 +1,22 @@
+"""Allows users to authenticate directly."""
+
+from flask import current_app as app
+from authlib.oauth2.rfc6749 import grants
+
+from gn3.auth import db
+from gn3.auth.authentication.users import valid_login, user_by_email
+
+from gn3.auth.authorisation.errors import NotFoundError
+
+class PasswordGrant(grants.ResourceOwnerPasswordCredentialsGrant):
+    """Implement the 'Password' grant."""
+    TOKEN_ENDPOINT_AUTH_METHODS = ["client_secret_basic", "client_secret_post"]
+
+    def authenticate_user(self, username, password):
+        "Authenticate the user with their username and password."
+        with db.connection(app.config["AUTH_DB"]) as conn:
+            try:
+                user = user_by_email(conn, username)
+                return user if valid_login(conn, user, password) else None
+            except NotFoundError as _nfe:
+                return None
diff --git a/gn_auth/auth/authentication/oauth2/models/__init__.py b/gn_auth/auth/authentication/oauth2/models/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/models/__init__.py
diff --git a/gn_auth/auth/authentication/oauth2/models/authorization_code.py b/gn_auth/auth/authentication/oauth2/models/authorization_code.py
new file mode 100644
index 0000000..f282814
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/models/authorization_code.py
@@ -0,0 +1,93 @@
+"""Model and functions for handling the Authorisation Code"""
+from uuid import UUID
+from datetime import datetime
+from typing import NamedTuple
+
+from pymonad.maybe import Just, Maybe, Nothing
+
+from gn3.auth import db
+
+from .oauth2client import OAuth2Client
+
+from ...users import User, user_by_id
+
+__5_MINUTES__ = 300 # in seconds
+
+class AuthorisationCode(NamedTuple):
+    """
+    The AuthorisationCode model for the auth(entic|oris)ation system.
+    """
+    # Instance variables
+    code_id: UUID
+    code: str
+    client: OAuth2Client
+    redirect_uri: str
+    scope: str
+    nonce: str
+    auth_time: int
+    code_challenge: str
+    code_challenge_method: str
+    user: User
+
+    @property
+    def response_type(self) -> str:
+        """
+        For authorisation code flow, the response_type type MUST always be
+        'code'.
+        """
+        return "code"
+
+    def is_expired(self):
+        """Check whether the code is expired."""
+        return self.auth_time + __5_MINUTES__ < datetime.now().timestamp()
+
+    def get_redirect_uri(self):
+        """Get the redirect URI"""
+        return self.redirect_uri
+
+    def get_scope(self):
+        """Return the assigned scope for this AuthorisationCode."""
+        return self.scope
+
+    def get_nonce(self):
+        """Get the one-time use token."""
+        return self.nonce
+
+def authorisation_code(conn: db.DbConnection ,
+                       code: str,
+                       client: OAuth2Client) -> Maybe[AuthorisationCode]:
+    """
+    Retrieve the authorisation code object that corresponds to `code` and the
+    given OAuth2 client.
+    """
+    with db.cursor(conn) as cursor:
+        query = ("SELECT * FROM authorisation_code "
+                 "WHERE code=:code AND client_id=:client_id")
+        cursor.execute(
+            query, {"code": code, "client_id": str(client.client_id)})
+        result = cursor.fetchone()
+        if result:
+            return Just(AuthorisationCode(
+                UUID(result["code_id"]), result["code"], client,
+                result["redirect_uri"], result["scope"], result["nonce"],
+                int(result["auth_time"]), result["code_challenge"],
+                result["code_challenge_method"],
+                user_by_id(conn, UUID(result["user_id"]))))
+        return Nothing
+
+def save_authorisation_code(conn: db.DbConnection,
+                            auth_code: AuthorisationCode) -> AuthorisationCode:
+    """Persist the `auth_code` into the database."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "INSERT INTO authorisation_code VALUES("
+            ":code_id, :code, :client_id, :redirect_uri, :scope, :nonce, "
+            ":auth_time, :code_challenge, :code_challenge_method, :user_id"
+            ")",
+            {
+                **auth_code._asdict(),
+                "code_id": str(auth_code.code_id),
+                "client_id": str(auth_code.client.client_id),
+                "user_id": str(auth_code.user.user_id)
+            })
+        return auth_code
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
new file mode 100644
index 0000000..2a307e3
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
@@ -0,0 +1,234 @@
+"""OAuth2 Client model."""
+import json
+import datetime
+from uuid import UUID
+from typing import Sequence, Optional, NamedTuple
+
+from pymonad.maybe import Just, Maybe, Nothing
+
+from gn3.auth import db
+from gn3.auth.authentication.users import User, users, user_by_id, same_password
+
+from gn3.auth.authorisation.errors import NotFoundError
+
+class OAuth2Client(NamedTuple):
+    """
+    Client to the OAuth2 Server.
+
+    This is defined according to the mixin at
+    https://docs.authlib.org/en/latest/specs/rfc6749.html#authlib.oauth2.rfc6749.ClientMixin
+    """
+    client_id: UUID
+    client_secret: str
+    client_id_issued_at: datetime.datetime
+    client_secret_expires_at: datetime.datetime
+    client_metadata: dict
+    user: User
+
+    def check_client_secret(self, client_secret: str) -> bool:
+        """Check whether the `client_secret` matches this client."""
+        return same_password(client_secret, self.client_secret)
+
+    @property
+    def token_endpoint_auth_method(self) -> str:
+        """Return the token endpoint authorisation method."""
+        return self.client_metadata.get("token_endpoint_auth_method", ["none"])
+
+    @property
+    def client_type(self) -> str:
+        """
+        Return the token endpoint authorisation method.
+
+        Acceptable client types:
+        * public: Unable to use registered client secrets, e.g. browsers, apps
+          on mobile devices.
+        * confidential: able to securely authenticate with authorisation server
+          e.g. being able to keep their registered client secret safe.
+        """
+        return self.client_metadata.get("client_type", "public")
+
+    def check_endpoint_auth_method(self, method: str, endpoint: str) -> bool:
+        """
+        Check if the client supports the given method for the given endpoint.
+
+        Acceptable methods:
+        * none: Client is a public client and does not have a client secret
+        * client_secret_post: Client uses the HTTP POST parameters
+        * client_secret_basic: Client uses HTTP Basic
+        """
+        if endpoint == "token":
+            return (method in self.token_endpoint_auth_method
+                    and method == "client_secret_post")
+        if endpoint in ("introspection", "revoke"):
+            return (method in self.token_endpoint_auth_method
+                    and method == "client_secret_basic")
+        return False
+
+    @property
+    def id(self):# pylint: disable=[invalid-name]
+        """Return the client_id."""
+        return self.client_id
+
+    @property
+    def grant_types(self) -> Sequence[str]:
+        """
+        Return the grant types that this client supports.
+
+        Valid grant types:
+        * authorisation_code
+        * implicit
+        * client_credentials
+        * password
+        """
+        return self.client_metadata.get("grant_types", [])
+
+    def check_grant_type(self, grant_type: str) -> bool:
+        """
+        Validate that client can handle the given grant types
+        """
+        return grant_type in self.grant_types
+
+    @property
+    def redirect_uris(self) -> Sequence[str]:
+        """Return the redirect_uris that this client supports."""
+        return self.client_metadata.get('redirect_uris', [])
+
+    def check_redirect_uri(self, redirect_uri: str) -> bool:
+        """
+        Check whether the given `redirect_uri` is one of the expected ones.
+        """
+        return redirect_uri in self.redirect_uris
+
+    @property
+    def response_types(self) -> Sequence[str]:
+        """Return the response_types that this client supports."""
+        return self.client_metadata.get("response_type", [])
+
+    def check_response_type(self, response_type: str) -> bool:
+        """Check whether this client supports `response_type`."""
+        return response_type in self.response_types
+
+    @property
+    def scope(self) -> Sequence[str]:
+        """Return valid scopes for this client."""
+        return tuple(set(self.client_metadata.get("scope", [])))
+
+    def get_allowed_scope(self, scope: str) -> str:
+        """Return list of scopes in `scope` that are supported by this client."""
+        if not bool(scope):
+            return ""
+        requested = scope.split()
+        return " ".join(sorted(set(
+            scp for scp in requested if scp in self.scope)))
+
+    def get_client_id(self):
+        """Return this client's identifier."""
+        return self.client_id
+
+    def get_default_redirect_uri(self) -> str:
+        """Return the default redirect uri"""
+        return self.client_metadata.get("default_redirect_uri", "")
+
+def client(conn: db.DbConnection, client_id: UUID,
+           user: Optional[User] = None) -> Maybe:
+    """Retrieve a client by its ID"""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT * FROM oauth2_clients WHERE client_id=?", (str(client_id),))
+        result = cursor.fetchone()
+        the_user = user
+        if result:
+            if not bool(the_user):
+                try:
+                    the_user = user_by_id(conn, result["user_id"])
+                except NotFoundError as _nfe:
+                    the_user = None
+
+            return Just(
+                OAuth2Client(UUID(result["client_id"]),
+                             result["client_secret"],
+                             datetime.datetime.fromtimestamp(
+                                 result["client_id_issued_at"]),
+                             datetime.datetime.fromtimestamp(
+                                 result["client_secret_expires_at"]),
+                             json.loads(result["client_metadata"]),
+                             the_user))# type: ignore[arg-type]
+
+    return Nothing
+
+def client_by_id_and_secret(conn: db.DbConnection, client_id: UUID,
+                            client_secret: str) -> OAuth2Client:
+    """Retrieve a client by its ID and secret"""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT * FROM oauth2_clients WHERE client_id=?",
+            (str(client_id),))
+        row = cursor.fetchone()
+        if bool(row) and same_password(client_secret, row["client_secret"]):
+            return OAuth2Client(
+                client_id, client_secret,
+                datetime.datetime.fromtimestamp(row["client_id_issued_at"]),
+                datetime.datetime.fromtimestamp(
+                    row["client_secret_expires_at"]),
+                json.loads(row["client_metadata"]),
+                user_by_id(conn, UUID(row["user_id"])))
+
+        raise NotFoundError("Could not find client with the given credentials.")
+
+def save_client(conn: db.DbConnection, the_client: OAuth2Client) -> OAuth2Client:
+    """Persist the client details into the database."""
+    with db.cursor(conn) as cursor:
+        query = (
+            "INSERT INTO oauth2_clients "
+            "(client_id, client_secret, client_id_issued_at, "
+            "client_secret_expires_at, client_metadata, user_id) "
+            "VALUES "
+            "(:client_id, :client_secret, :client_id_issued_at, "
+            ":client_secret_expires_at, :client_metadata, :user_id) "
+            "ON CONFLICT (client_id) DO UPDATE SET "
+            "client_secret=:client_secret, "
+            "client_id_issued_at=:client_id_issued_at, "
+            "client_secret_expires_at=:client_secret_expires_at, "
+            "client_metadata=:client_metadata, user_id=:user_id")
+        cursor.execute(
+            query,
+            {
+                "client_id": str(the_client.client_id),
+                "client_secret": the_client.client_secret,
+                "client_id_issued_at": (
+                    the_client.client_id_issued_at.timestamp()),
+                "client_secret_expires_at": (
+                    the_client.client_secret_expires_at.timestamp()),
+                "client_metadata": json.dumps(the_client.client_metadata),
+                "user_id": str(the_client.user.user_id)
+            })
+        return the_client
+
+def oauth2_clients(conn: db.DbConnection) -> tuple[OAuth2Client, ...]:
+    """Fetch a list of all OAuth2 clients."""
+    with db.cursor(conn) as cursor:
+        cursor.execute("SELECT * FROM oauth2_clients")
+        clients_rs = cursor.fetchall()
+        the_users = {
+            usr.user_id: usr for usr in users(
+                conn, tuple({UUID(result["user_id"]) for result in clients_rs}))
+        }
+        return tuple(OAuth2Client(UUID(result["client_id"]),
+                                  result["client_secret"],
+                                  datetime.datetime.fromtimestamp(
+                                     result["client_id_issued_at"]),
+                                  datetime.datetime.fromtimestamp(
+                                     result["client_secret_expires_at"]),
+                                  json.loads(result["client_metadata"]),
+                                  the_users[UUID(result["user_id"])])
+                     for result in clients_rs)
+
+def delete_client(conn: db.DbConnection, the_client: OAuth2Client) -> OAuth2Client:
+    """Delete the given client from the database"""
+    with db.cursor(conn) as cursor:
+        params = (str(the_client.client_id),)
+        cursor.execute("DELETE FROM authorisation_code WHERE client_id=?",
+                       params)
+        cursor.execute("DELETE FROM oauth2_tokens WHERE client_id=?", params)
+        cursor.execute("DELETE FROM oauth2_clients WHERE client_id=?", params)
+        return the_client
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2token.py b/gn_auth/auth/authentication/oauth2/models/oauth2token.py
new file mode 100644
index 0000000..72e20cc
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2token.py
@@ -0,0 +1,132 @@
+"""OAuth2 Token"""
+import uuid
+import datetime
+from typing import NamedTuple, Optional
+
+from pymonad.maybe import Just, Maybe, Nothing
+
+from gn3.auth import db
+from gn3.auth.authentication.users import User, user_by_id
+
+from gn3.auth.authorisation.errors import NotFoundError
+
+from .oauth2client import client, OAuth2Client
+
+class OAuth2Token(NamedTuple):
+    """Implement Tokens for OAuth2."""
+    token_id: uuid.UUID
+    client: OAuth2Client
+    token_type: str
+    access_token: str
+    refresh_token: Optional[str]
+    scope: str
+    revoked: bool
+    issued_at: datetime.datetime
+    expires_in: int
+    user: User
+
+    @property
+    def expires_at(self) -> datetime.datetime:
+        """Return the time when the token expires."""
+        return self.issued_at + datetime.timedelta(seconds=self.expires_in)
+
+    def check_client(self, client: OAuth2Client) -> bool:# pylint: disable=[redefined-outer-name]
+        """Check whether the token is issued to given `client`."""
+        return client.client_id == self.client.client_id
+
+    def get_expires_in(self) -> int:
+        """Return the `expires_in` value for the token."""
+        return self.expires_in
+
+    def get_scope(self) -> str:
+        """Return the valid scope for the token."""
+        return self.scope
+
+    def is_expired(self) -> bool:
+        """Check whether the token is expired."""
+        return self.expires_at < datetime.datetime.now()
+
+    def is_revoked(self):
+        """Check whether the token has been revoked."""
+        return self.revoked
+
+def __token_from_resultset__(conn: db.DbConnection, rset) -> Maybe:
+    __identity__ = lambda val: val
+    try:
+        the_user = user_by_id(conn, uuid.UUID(rset["user_id"]))
+    except NotFoundError as _nfe:
+        the_user = None
+    the_client = client(conn, uuid.UUID(rset["client_id"]), the_user)
+
+    if the_client.is_just() and bool(the_user):
+        return Just(OAuth2Token(token_id=uuid.UUID(rset["token_id"]),
+                                client=the_client.maybe(None, __identity__),
+                                token_type=rset["token_type"],
+                                access_token=rset["access_token"],
+                                refresh_token=rset["refresh_token"],
+                                scope=rset["scope"],
+                                revoked=(rset["revoked"] == 1),
+                                issued_at=datetime.datetime.fromtimestamp(
+                                    rset["issued_at"]),
+                                expires_in=rset["expires_in"],
+                                user=the_user))# type: ignore[arg-type]
+
+    return Nothing
+
+def token_by_access_token(conn: db.DbConnection, token_str: str) -> Maybe:
+    """Retrieve token by its token string"""
+    with db.cursor(conn) as cursor:
+        cursor.execute("SELECT * FROM oauth2_tokens WHERE access_token=?",
+                       (token_str,))
+        res = cursor.fetchone()
+        if res:
+            return __token_from_resultset__(conn, res)
+
+    return Nothing
+
+def token_by_refresh_token(conn: db.DbConnection, token_str: str) -> Maybe:
+    """Retrieve token by its token string"""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT * FROM oauth2_tokens WHERE refresh_token=?",
+            (token_str,))
+        res = cursor.fetchone()
+        if res:
+            return __token_from_resultset__(conn, res)
+
+    return Nothing
+
+def revoke_token(token: OAuth2Token) -> OAuth2Token:
+    """
+    Return a new token derived from `token` with the `revoked` field set to
+    `True`.
+    """
+    return OAuth2Token(
+        token_id=token.token_id, client=token.client,
+        token_type=token.token_type, access_token=token.access_token,
+        refresh_token=token.refresh_token, scope=token.scope, revoked=True,
+        issued_at=token.issued_at, expires_in=token.expires_in, user=token.user)
+
+def save_token(conn: db.DbConnection, token: OAuth2Token) -> None:
+    """Save/Update the token."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            ("INSERT INTO oauth2_tokens VALUES (:token_id, :client_id, "
+             ":token_type, :access_token, :refresh_token, :scope, :revoked, "
+             ":issued_at, :expires_in, :user_id) "
+             "ON CONFLICT (token_id) DO UPDATE SET "
+             "refresh_token=:refresh_token, revoked=:revoked, "
+             "expires_in=:expires_in "
+             "WHERE token_id=:token_id"),
+            {
+                "token_id": str(token.token_id),
+                "client_id": str(token.client.client_id),
+                "token_type": token.token_type,
+                "access_token": token.access_token,
+                "refresh_token": token.refresh_token,
+                "scope": token.scope,
+                "revoked": 1 if token.revoked else 0,
+                "issued_at": int(token.issued_at.timestamp()),
+                "expires_in": token.expires_in,
+                "user_id": str(token.user.user_id)
+            })
diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py
new file mode 100644
index 0000000..223e811
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/resource_server.py
@@ -0,0 +1,19 @@
+"""Protect the resources endpoints"""
+
+from flask import current_app as app
+from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator
+from authlib.integrations.flask_oauth2 import ResourceProtector
+
+from gn3.auth import db
+from gn3.auth.authentication.oauth2.models.oauth2token import token_by_access_token
+
+class BearerTokenValidator(_BearerTokenValidator):
+    """Extends `authlib.oauth2.rfc6750.BearerTokenValidator`"""
+    def authenticate_token(self, token_string: str):
+        with db.connection(app.config["AUTH_DB"]) as conn:
+            return token_by_access_token(conn, token_string).maybe(# type: ignore[misc]
+                None, lambda tok: tok)
+
+require_oauth = ResourceProtector()
+
+require_oauth.register_token_validator(BearerTokenValidator())
diff --git a/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py
new file mode 100644
index 0000000..7d7113a
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/server.py
@@ -0,0 +1,72 @@
+"""Initialise the OAuth2 Server"""
+import uuid
+import datetime
+from typing import Callable
+
+from flask import Flask, current_app
+from authlib.oauth2.rfc6749.errors import InvalidClientError
+from authlib.integrations.flask_oauth2 import AuthorizationServer
+# from authlib.oauth2.rfc7636 import CodeChallenge
+
+from gn3.auth import db
+
+from .models.oauth2client import client
+from .models.oauth2token import OAuth2Token, save_token
+
+from .grants.password_grant import PasswordGrant
+from .grants.authorisation_code_grant import AuthorisationCodeGrant
+
+from .endpoints.revocation import RevocationEndpoint
+from .endpoints.introspection import IntrospectionEndpoint
+
+def create_query_client_func() -> Callable:
+    """Create the function that loads the client."""
+    def __query_client__(client_id: uuid.UUID):
+        # use current_app rather than passing the db_uri to avoid issues
+        # when config changes, e.g. while testing.
+        with db.connection(current_app.config["AUTH_DB"]) as conn:
+            the_client = client(conn, client_id).maybe(
+                None, lambda clt: clt) # type: ignore[misc]
+            if bool(the_client):
+                return the_client
+            raise InvalidClientError(
+                "No client found for the given CLIENT_ID and CLIENT_SECRET.")
+
+    return __query_client__
+
+def create_save_token_func(token_model: type) -> Callable:
+    """Create the function that saves the token."""
+    def __save_token__(token, request):
+        with db.connection(current_app.config["AUTH_DB"]) as conn:
+            save_token(
+                conn, token_model(
+                    token_id=uuid.uuid4(), client=request.client,
+                    user=request.user,
+                    **{
+                        "refresh_token": None, "revoked": False,
+                        "issued_at": datetime.datetime.now(),
+                        **token
+                    }))
+
+    return __save_token__
+
+def setup_oauth2_server(app: Flask) -> None:
+    """Set's up the oauth2 server for the flask application."""
+    server = AuthorizationServer()
+    server.register_grant(PasswordGrant)
+
+    # Figure out a common `code_verifier` for GN2 and GN3 and set
+    # server.register_grant(AuthorisationCodeGrant, [CodeChallenge(required=False)])
+    # below
+    server.register_grant(AuthorisationCodeGrant)
+
+    # register endpoints
+    server.register_endpoint(RevocationEndpoint)
+    server.register_endpoint(IntrospectionEndpoint)
+
+    # init server
+    server.init_app(
+        app,
+        query_client=create_query_client_func(),
+        save_token=create_save_token_func(OAuth2Token))
+    app.config["OAUTH2_SERVER"] = server
diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py
new file mode 100644
index 0000000..2bd3865
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/views.py
@@ -0,0 +1,104 @@
+"""Endpoints for the oauth2 server"""
+import uuid
+import traceback
+
+from authlib.oauth2.rfc6749.errors import InvalidClientError
+from email_validator import validate_email, EmailNotValidError
+from flask import (
+    flash,
+    request,
+    url_for,
+    redirect,
+    Response,
+    Blueprint,
+    render_template,
+    current_app as app)
+
+from gn3.auth import db
+from gn3.auth.db_utils import with_db_connection
+from gn3.auth.authorisation.errors import ForbiddenAccess
+
+from .resource_server import require_oauth
+from .endpoints.revocation import RevocationEndpoint
+from .endpoints.introspection import IntrospectionEndpoint
+
+from ..users import valid_login, NotFoundError, user_by_email
+
+auth = Blueprint("auth", __name__)
+
+@auth.route("/delete-client/<uuid:client_id>", methods=["GET", "POST"])
+def delete_client(client_id: uuid.UUID):
+    """Delete an OAuth2 client."""
+    return f"WOULD DELETE OAUTH2 CLIENT {client_id}."
+
+@auth.route("/authorise", methods=["GET", "POST"])
+def authorise():
+    """Authorise a user"""
+    try:
+        server = app.config["OAUTH2_SERVER"]
+        client_id = uuid.UUID(request.args.get(
+            "client_id",
+            request.form.get("client_id", str(uuid.uuid4()))))
+        client = server.query_client(client_id)
+        if not bool(client):
+            flash("Invalid OAuth2 client.", "alert-error")
+        if request.method == "GET":
+            client = server.query_client(request.args.get("client_id"))
+            return render_template(
+                "oauth2/authorise-user.html",
+                client=client,
+                scope=client.scope,
+                response_type="code")
+
+        form = request.form
+        def __authorise__(conn: db.DbConnection) -> Response:
+            email_passwd_msg = "Email or password is invalid!"
+            redirect_response = redirect(url_for("oauth2.auth.authorise",
+                                                 client_id=client_id))
+            try:
+                email = validate_email(
+                    form.get("user:email"), check_deliverability=False)
+                user = user_by_email(conn, email["email"])
+                if valid_login(conn, user, form.get("user:password", "")):
+                    return server.create_authorization_response(request=request, grant_user=user)
+                flash(email_passwd_msg, "alert-error")
+                return redirect_response # type: ignore[return-value]
+            except EmailNotValidError as _enve:
+                app.logger.debug(traceback.format_exc())
+                flash(email_passwd_msg, "alert-error")
+                return redirect_response # type: ignore[return-value]
+            except NotFoundError as _nfe:
+                app.logger.debug(traceback.format_exc())
+                flash(email_passwd_msg, "alert-error")
+                return redirect_response # type: ignore[return-value]
+
+        return with_db_connection(__authorise__)
+    except InvalidClientError as ice:
+        return render_template(
+            "oauth2/oauth2_error.html", error=ice), ice.status_code
+
+@auth.route("/token", methods=["POST"])
+def token():
+    """Retrieve the authorisation token."""
+    server = app.config["OAUTH2_SERVER"]
+    return server.create_token_response()
+
+@auth.route("/revoke", methods=["POST"])
+def revoke_token():
+    """Revoke the token."""
+    return app.config["OAUTH2_SERVER"].create_endpoint_response(
+        RevocationEndpoint.ENDPOINT_NAME)
+
+@auth.route("/introspect", methods=["POST"])
+@require_oauth("introspect")
+def introspect_token() -> Response:
+    """Provide introspection information for the token."""
+    # This is dangerous to provide publicly
+    authorised_clients = app.config.get(
+        "OAUTH2_CLIENTS_WITH_INTROSPECTION_PRIVILEGE", [])
+    with require_oauth.acquire("introspect") as the_token:
+        if the_token.client.client_id in authorised_clients:
+            return app.config["OAUTH2_SERVER"].create_endpoint_response(
+                IntrospectionEndpoint.ENDPOINT_NAME)
+
+    raise ForbiddenAccess("You cannot access this endpoint")