Skip to content

Commit

Permalink
[WIP] 11 basic auth layer (#15)
Browse files Browse the repository at this point in the history
* accept reformatting

* accept reformatting

* seperate validators

* accept reformatting
  • Loading branch information
SerRichard authored Jan 25, 2024
1 parent f9ec761 commit 87113bd
Show file tree
Hide file tree
Showing 8 changed files with 328 additions and 1 deletion.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Included is a vscode dev container which is intended to be used as the developme
```
# From /openeo-fastapi
poetry config virtualenvs.path "<I tend to set this to the repo. I.e, ~/openeo-fastapi/.venv>"
poetry lock
poetry install --all-extras
Expand Down
126 changes: 126 additions & 0 deletions openeo_fastapi/client/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from abc import ABC, abstractmethod, abstractproperty
from enum import Enum

import requests
from pydantic import BaseModel, ValidationError, validator

from openeo_fastapi.client.exceptions import (
InvalidIssuerConfig,
TokenCantBeValidated,
TokenInvalid,
)

OIDC_WELLKNOWN_CONFIG_PATH = "/.well-known/openid-configuration"
OIDC_USERINFO = "userinfo_endpoint"


class Authenticator(ABC):
# Authenticator validate method needs to know what decisions to make based on user info response from the issuer handler.
# This will be different for different backends, so just put it as ABC for now. We might be able to define this if we want
# to specify an auth config when initialising the backend.
@abstractmethod
def validate(self):
pass


class AuthMethod(Enum):
"""Enum defining known auth methods."""

BASIC = "basic"
OIDC = "oidc"


# Breaks the OpenEO token format down into it's components. This makes it possible to use the token against the issuer.
class AuthToken(BaseModel):
""" """

bearer: bool
method: AuthMethod
provider: str
token: str

@validator("bearer", pre=True)
def passwords_match(cls, v, values, **kwargs):
if v != "Bearer ":
return ValueError("Token not formatted correctly")
return True

@validator("provider", pre=True)
def check_provider(cls, v, values, **kwargs):
if v == "":
raise ValidationError("Empty provider string.")
return v

@validator("token", pre=True)
def check_token(cls, v, values, **kwargs):
if v == "":
raise ValidationError("Empty token string.")
return v

@classmethod
def from_token(cls, token: str):
"""Takes the openeo format token, splits it into the component parts, and returns an Auth token."""
return cls(
**dict(zip(["bearer", "method", "provider", "token"], token.split("/")))
)


# TODO Remove? Would be good to generate the user info model for each issuer that is provided.
class UserInfo(BaseModel):
""" """

info: dict


class IssuerHandler(BaseModel):
"""General token handler for querying provided tokens against issuers."""

issuer_url: str
organisation: str
# TODO Roles will need to be used by the Authenticator class to be checked against the user info.
roles: list

@validator("issuer_url", pre=True)
def remove_trailing_slash(cls, v, values, **kwargs):
if v.endswith("/"):
return v.removesuffix("/")
return v

def _get_issuer_config(self):
""" """
return requests.get(self.issuer_url + OIDC_WELLKNOWN_CONFIG_PATH)

def _get_user_info(self, info_endpoint, token):
""" """
return requests.get(
info_endpoint,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
)

def _validate_oidc_token(self, token: str) -> UserInfo:
""" """

issuer_oidc_config = self._get_issuer_config()

if issuer_oidc_config.status_code != 200:
raise InvalidIssuerConfig()

userinfo_url = issuer_oidc_config.json()[OIDC_USERINFO]
resp = self._get_user_info(userinfo_url, token)

if resp.status_code != 200:
raise TokenInvalid()

return UserInfo(info=resp.json())

def validate_token(self, token: str) -> UserInfo:
"""Try to validate the token against the give OIDC provider."""
# TODO Handle validation exceptions
parsed_token = AuthToken.from_token(token)

if parsed_token.method.value == AuthMethod.OIDC.value:
return self._validate_oidc_token(parsed_token.token)
raise TokenCantBeValidated()
16 changes: 16 additions & 0 deletions openeo_fastapi/client/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class TokenInvalid(Exception):
""" """

pass


class TokenCantBeValidated(Exception):
""" """

pass


class InvalidIssuerConfig(Exception):
""" """

pass
2 changes: 2 additions & 0 deletions poetry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[virtualenvs]
create = true
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ attrs = "^23.1.0"
httpx = "^0.24.1"
pip = "^23.3.2"
ipykernel = "^6.28.0"
requests = "^2.31.0"


[tool.poetry.group.dev.dependencies]
Expand Down
File renamed without changes.
112 changes: 112 additions & 0 deletions tests/client/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from unittest.mock import patch

import pytest
from pydantic import ValidationError

from openeo_fastapi.client import auth, exceptions

BASIC_TOKEN_EXAMPLE = "Bearer /basic/openeo/rubbish.not.a.token"
OIDC_TOKEN_EXAMPLE = "Bearer /oidc/issuer/rubbish.not.a.token"

INVALID_TOKEN_EXAMPLE_1 = "bearer /basic/openeo/rubbish.not.a.token"
INVALID_TOKEN_EXAMPLE_2 = "Bearer /basicopeneorubbish.not.a.token"
INVALID_TOKEN_EXAMPLE_3 = "Bearer //openeo/rubbish.not.a.token"
INVALID_TOKEN_EXAMPLE_4 = "Bearer /basic//rubbish.not.a.token"
INVALID_TOKEN_EXAMPLE_5 = "Bearer /basic/openeo/"


def test_auth_method():
BASIC_VALUE = "basic"
OIDC_VALUE = "oidc"

basic = auth.AuthMethod(BASIC_VALUE)
oidc = auth.AuthMethod(OIDC_VALUE)

assert basic.value == BASIC_VALUE
assert oidc.value == OIDC_VALUE

with pytest.raises(ValueError):
auth.AuthMethod("wrong")


def test_auth_token():
def token_checks(token: auth.AuthToken, method: str, provider: str):
assert token.bearer
assert token.method.value == method
assert token.provider == provider

basic_token = auth.AuthToken.from_token(BASIC_TOKEN_EXAMPLE)
token_checks(basic_token, "basic", "openeo")

oidc_token = auth.AuthToken.from_token(OIDC_TOKEN_EXAMPLE)
token_checks(oidc_token, "oidc", "issuer")

# Check cases of invalid format raise a validation error.
with pytest.raises(ValidationError):
auth.AuthToken.from_token(INVALID_TOKEN_EXAMPLE_1)

with pytest.raises(ValidationError):
auth.AuthToken.from_token(INVALID_TOKEN_EXAMPLE_2)

with pytest.raises(ValidationError):
auth.AuthToken.from_token(INVALID_TOKEN_EXAMPLE_3)

with pytest.raises(ValidationError):
auth.AuthToken.from_token(INVALID_TOKEN_EXAMPLE_4)

with pytest.raises(ValidationError):
auth.AuthToken.from_token(INVALID_TOKEN_EXAMPLE_5)


def test_issuer_handler_init():
test_issuer = auth.IssuerHandler(
issuer_url="http://issuer.mycloud/",
organisation="mycloud",
roles=["admin", "user"],
)

# Check trailing slash removal
assert not test_issuer.issuer_url.endswith("/")
assert test_issuer.organisation == "mycloud"


def test_issuer_handler__validate_oidc_token(
mocked_oidc_config, mocked_oidc_userinfo, mocked_issuer
):
info = mocked_issuer._validate_oidc_token(token=OIDC_TOKEN_EXAMPLE)
assert isinstance(info, auth.UserInfo)


def test_issuer_handler__validate_oidc_token_bad_config(
mocked_bad_oidc_config, mocked_oidc_userinfo, mocked_issuer
):
with pytest.raises(exceptions.InvalidIssuerConfig):
mocked_issuer._validate_oidc_token(token=OIDC_TOKEN_EXAMPLE)


def test_issuer_handler__validate_oidc_token_bad_userinfo(
mocked_oidc_config, mocked_bad_oidc_userinfo, mocked_issuer
):
with pytest.raises(exceptions.TokenInvalid):
mocked_issuer._validate_oidc_token(token=OIDC_TOKEN_EXAMPLE)


def test_issuer_handler_validate_oidc_token(
mocked_oidc_config, mocked_oidc_userinfo, mocked_issuer
):
info = mocked_issuer.validate_token(token=OIDC_TOKEN_EXAMPLE)
assert isinstance(info, auth.UserInfo)


def test_issuer_handler_validate_basic_token(
mocked_oidc_config, mocked_oidc_userinfo, mocked_issuer
):
with pytest.raises(exceptions.TokenCantBeValidated):
mocked_issuer.validate_token(token=BASIC_TOKEN_EXAMPLE)


def test_issuer_handler_validate_broken_token(
mocked_oidc_config, mocked_oidc_userinfo, mocked_issuer
):
with pytest.raises(ValidationError):
mocked_issuer.validate_token(token=INVALID_TOKEN_EXAMPLE_1)
70 changes: 69 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import json
from unittest.mock import patch

import pytest
from fastapi import FastAPI
from requests import Response

from openeo_fastapi.api.app import OpenEOApi
from openeo_fastapi.client import models
from openeo_fastapi.client import auth, models
from openeo_fastapi.client.core import OpenEOCore


Expand Down Expand Up @@ -40,3 +44,67 @@ def core_api():
api = OpenEOApi(client=client, app=FastAPI())

return api


@pytest.fixture()
def mocked_oidc_config():
resp_content_bytes = json.dumps(
{"userinfo_endpoint": "http://nothere.test"}
).encode("utf-8")

mocked_response = Response()
mocked_response.status_code = 200
mocked_response._content = resp_content_bytes

with patch("openeo_fastapi.client.auth.IssuerHandler._get_issuer_config") as mock:
mock.return_value = mocked_response
yield mock


@pytest.fixture()
def mocked_oidc_userinfo():
resp_content_bytes = json.dumps(
{
"eduperson_entitlement": [
"entitlment",
],
"sub": "someuser@testing.test",
}
).encode("utf-8")

mocked_response = Response()
mocked_response.status_code = 200
mocked_response._content = resp_content_bytes

with patch("openeo_fastapi.client.auth.IssuerHandler._get_user_info") as mock:
mock.return_value = mocked_response
yield mock


@pytest.fixture()
def mocked_bad_oidc_config():
mocked_response = Response()
mocked_response.status_code = 404

with patch("openeo_fastapi.client.auth.IssuerHandler._get_issuer_config") as mock:
mock.return_value = mocked_response
yield mock


@pytest.fixture()
def mocked_bad_oidc_userinfo():
mocked_response = Response()
mocked_response.status_code = 404

with patch("openeo_fastapi.client.auth.IssuerHandler._get_user_info") as mock:
mock.return_value = mocked_response
yield mock


@pytest.fixture()
def mocked_issuer():
return auth.IssuerHandler(
issuer_url="http://issuer.mycloud/",
organisation="mycloud",
roles=["admin", "user"],
)

0 comments on commit 87113bd

Please sign in to comment.