Skip to content

Commit

Permalink
refactor: Clean up authentication code; add tests for OIDC
Browse files Browse the repository at this point in the history
  • Loading branch information
MoritzWeber0 committed Jul 31, 2024
1 parent 4ca31f3 commit b3aee1c
Show file tree
Hide file tree
Showing 64 changed files with 1,771 additions and 4,100 deletions.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
*.{sh,py} text eol=lf
.git_archival.txt export-subst
*.mp4 filter=lfs diff=lfs merge=lfs -text
frontend/src/app/openapi/** linguist-generated=true
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ helm-deploy:
--set docker.registry.sessions=$(CAPELLACOLLAB_SESSIONS_REGISTRY) \
--set docker.tag=$(DOCKER_TAG) \
--set mocks.oauth=True \
--set authentication.claimMapping.username=sub \
--set authentication.endpoints.authorization=https://localhost/default/authorize \
--set backend.authentication.claimMapping.username=sub \
--set backend.authentication.endpoints.authorization=https://localhost/default/authorize \
--set development=$(DEVELOPMENT_MODE) \
--set cluster.ingressClassName=traefik \
--set cluster.ingressNamespace=kube-system \
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

"""Commit message
"""Add column for Jupyter token
Revision ID: f3d2dedd7906
Revises: 4df9c82766e2
Expand Down
20 changes: 15 additions & 5 deletions backend/capellacollab/core/authentication/api_key_cookie.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from capellacollab.config import config

from . import exceptions, oidc_provider
from . import exceptions, oidc

log = logging.getLogger(__name__)

Expand All @@ -21,7 +21,7 @@
class JWTConfig:
_jwks_client = None

def __init__(self, oidc_config: oidc_provider.AbstractOIDCProviderConfig):
def __init__(self, oidc_config: oidc.OIDCProviderConfig):
self.oidc_config = oidc_config

if JWTConfig._jwks_client is None:
Expand All @@ -32,10 +32,10 @@ def __init__(self, oidc_config: oidc_provider.AbstractOIDCProviderConfig):


class JWTAPIKeyCookie(security.APIKeyCookie):
def __init__(self, oidc_config: oidc_provider.AbstractOIDCProviderConfig):
def __init__(self):
super().__init__(name="id_token", auto_error=True)
self.oidc_config = oidc_config
self.jwt_config = JWTConfig(oidc_config)
self.oidc_config = oidc.get_cached_oidc_config()
self.jwt_config = JWTConfig(self.oidc_config)

async def __call__(self, request: fastapi.Request) -> str:
token: str | None = await super().__call__(request)

Check warning on line 41 in backend/capellacollab/core/authentication/api_key_cookie.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/api_key_cookie.py#L41

Added line #L41 was not covered by tests
Expand All @@ -62,6 +62,16 @@ def validate_token(self, token: str) -> dict[str, t.Any]:
)
except jwt_exceptions.ExpiredSignatureError:
raise exceptions.TokenSignatureExpired()

Check warning on line 64 in backend/capellacollab/core/authentication/api_key_cookie.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/api_key_cookie.py#L64

Added line #L64 was not covered by tests
except jwt_exceptions.InvalidIssuerError:
log.exception(

Check warning on line 66 in backend/capellacollab/core/authentication/api_key_cookie.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/api_key_cookie.py#L66

Added line #L66 was not covered by tests
"Expected issuer '%s'. Got '%s'",
self.oidc_config.get_issuer(),
jwt.decode(
jwt=token,
options={"verify_signature": False},
)["iss"],
)
raise exceptions.JWTValidationFailed()
except jwt_exceptions.PyJWTError:
log.exception("JWT validation failed", exc_info=True)
raise exceptions.JWTValidationFailed()

Check warning on line 77 in backend/capellacollab/core/authentication/api_key_cookie.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/api_key_cookie.py#L74-L77

Added lines #L74 - L77 were not covered by tests
Expand Down
26 changes: 2 additions & 24 deletions backend/capellacollab/core/authentication/injectables.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from __future__ import annotations

import dataclasses
import functools
import logging

import fastapi
Expand All @@ -24,28 +23,11 @@
from capellacollab.users import exceptions as users_exceptions
from capellacollab.users import models as users_models

from . import exceptions, oidc_provider
from . import exceptions

logger = logging.getLogger(__name__)


@functools.lru_cache
def get_cached_oidc_config() -> oidc_provider.AbstractOIDCProviderConfig:
return oidc_provider.WellKnownOIDCProviderConfig()


async def get_oidc_config() -> oidc_provider.AbstractOIDCProviderConfig:
return get_cached_oidc_config()


async def get_oidc_provider(
oidc_config: oidc_provider.AbstractOIDCProviderConfig = fastapi.Depends(
get_oidc_config
),
) -> oidc_provider.AbstractOIDCProvider:
return oidc_provider.OIDCProvider(oidc_config)


class OpenAPIFakeBase(security_base.SecurityBase):
"""Fake class to display the authentication methods in the OpenAPI docs
Expand Down Expand Up @@ -79,13 +61,10 @@ class OpenAPIPersonalAccessToken(OpenAPIFakeBase):

async def get_username(
request: fastapi.Request,
oidc_config: oidc_provider.AbstractOIDCProviderConfig = fastapi.Depends(
get_oidc_config
),
_unused1=fastapi.Depends(OpenAPIPersonalAccessToken()),
) -> str:
if request.cookies.get("id_token"):
username = await api_key_cookie.JWTAPIKeyCookie(oidc_config)(request)
username = await api_key_cookie.JWTAPIKeyCookie()(request)
return username

authorization = request.headers.get("Authorization")
Expand All @@ -99,7 +78,6 @@ async def get_username(
case _:
raise exceptions.UnknownScheme(scheme)

assert username
return username


Expand Down
7 changes: 7 additions & 0 deletions backend/capellacollab/core/authentication/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@ class TokenRequest(core_pydantic.BaseModel):
code: str
nonce: str
code_verifier: str


class AuthorizationResponse(core_pydantic.BaseModel):
auth_url: str
state: str
nonce: str
code_verifier: str
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

import abc
import functools
import logging
import typing as t

Expand All @@ -10,46 +10,12 @@

from capellacollab.config import config

from . import exceptions
from . import exceptions, models

logger = logging.getLogger(__name__)


class AbstractOIDCProviderConfig(abc.ABC):
@abc.abstractmethod
def get_authorization_endpoint(self) -> str:
pass

@abc.abstractmethod
def get_token_endpoint(self) -> str:
pass

@abc.abstractmethod
def get_jwks_uri(self) -> str:
pass

@abc.abstractmethod
def get_supported_signing_algorithms(self) -> list[str]:
pass

@abc.abstractmethod
def get_issuer(self) -> str:
pass

@abc.abstractmethod
def get_scopes(self) -> list[str]:
pass

@abc.abstractmethod
def get_client_id(self) -> str:
pass

@abc.abstractmethod
def get_client_secret(self) -> str:
pass


class WellKnownOIDCProviderConfig(AbstractOIDCProviderConfig):
class OIDCProviderConfig:
def __init__(self):
self.well_known_uri = config.authentication.endpoints.well_known
self.well_known = self._fetch_well_known_configuration()
Expand Down Expand Up @@ -92,32 +58,16 @@ def get_client_id(self) -> str:
return config.authentication.client.id


class AbstractOIDCProvider(abc.ABC):
def __init__(self, oidc_config: AbstractOIDCProviderConfig):
self.oidc_config = oidc_config
@functools.lru_cache
def get_cached_oidc_config() -> OIDCProviderConfig:
return OIDCProviderConfig()

@abc.abstractmethod
def get_authorization_url_with_parameters(
self,
) -> t.Tuple[str, str, str, str]:
pass

@abc.abstractmethod
def exchange_code_for_tokens(
self, authorization_code: str, code_verifier: str
) -> dict[str, t.Any]:
pass

@abc.abstractmethod
def refresh_token(self, _refresh_token: str) -> dict[str, t.Any]:
pass


class OIDCProvider(AbstractOIDCProvider):
class OIDCProvider:
CODE_CHALLENGE_METHOD = "S256"

def __init__(self, oidc_config: AbstractOIDCProviderConfig):
super().__init__(oidc_config)
def __init__(self):
self.oidc_config = get_cached_oidc_config()
self.web_client: oauth2.WebApplicationClient = (
oauth2.WebApplicationClient(
client_id=self.oidc_config.get_client_id()
Expand All @@ -126,10 +76,10 @@ def __init__(self, oidc_config: AbstractOIDCProviderConfig):

def get_authorization_url_with_parameters(
self,
) -> t.Tuple[str, str, str, str]:
) -> models.AuthorizationResponse:
state = common.generate_token()

nonce = common.generate_nonce()

code_verifier = self.web_client.create_code_verifier(length=43)
code_challenge = self.web_client.create_code_challenge(
code_verifier, OIDCProvider.CODE_CHALLENGE_METHOD
Expand All @@ -145,7 +95,12 @@ def get_authorization_url_with_parameters(
code_challenge_method=OIDCProvider.CODE_CHALLENGE_METHOD,
)

return (auth_url, state, nonce, code_verifier)
return models.AuthorizationResponse(
auth_url=auth_url,
state=state,
nonce=nonce,
code_verifier=code_verifier,
)

def exchange_code_for_tokens(
self, authorization_code: str, code_verifier: str
Expand Down
Loading

0 comments on commit b3aee1c

Please sign in to comment.