From 5ef935781bc6256f8c5d60f66ee898a284716919 Mon Sep 17 00:00:00 2001 From: dominik003 Date: Mon, 10 Jun 2024 18:39:56 +0200 Subject: [PATCH 1/8] feat: Rework authentication logic This commit serves two main purposes. First, it merges the code for the two authentication providers, azure and oauth. To do this, we no longer rely on the azure-specific MSAL library, but use a more general library that we already use for non-azure providers. Second, we change our authentication scheme from a bearer token provided in the header and stored in a frontend service to secure cookies. This eliminates an XSS attack vector as the tokens are no longer accessible on the client side. --- backend/capellacollab/__main__.py | 13 +- backend/capellacollab/config/models.py | 66 ++------ .../core/authentication/__init__.py | 21 --- .../core/authentication/api_key_cookie.py | 75 +++++++++ .../core/authentication/basic_auth.py | 19 +-- .../core/authentication/exceptions.py | 32 +++- .../capellacollab/core/authentication/flow.py | 126 ++++++++++++++++ .../core/authentication/injectables.py | 22 +-- .../core/authentication/jwt_bearer.py | 78 ---------- .../core/authentication/models.py | 11 ++ .../core/authentication/provider/__init__.py | 2 - .../authentication/provider/azure/__init__.py | 2 - .../authentication/provider/azure/__main__.py | 24 --- .../authentication/provider/azure/keystore.py | 109 -------------- .../authentication/provider/azure/routes.py | 112 -------------- .../core/authentication/provider/models.py | 28 ---- .../authentication/provider/oauth/__init__.py | 2 - .../authentication/provider/oauth/__main__.py | 23 --- .../authentication/provider/oauth/flow.py | 88 ----------- .../authentication/provider/oauth/keystore.py | 104 ------------- .../authentication/provider/oauth/routes.py | 65 -------- .../core/authentication/routes.py | 142 ++++++++++++++++++ .../core/authentication/schemas.py | 14 -- backend/capellacollab/core/responses.py | 35 ++++- backend/capellacollab/routes.py | 12 +- backend/capellacollab/sessions/models.py | 8 - backend/capellacollab/sessions/routes.py | 11 +- backend/pyproject.toml | 13 +- backend/tests/conftest.py | 10 +- backend/tests/core/conftest.py | 41 +++++ backend/tests/core/test_auth.py | 130 ++++++++++++++++ .../tests/projects/test_projects_routes.py | 6 - backend/tests/sessions/test_session_hooks.py | 8 +- .../tests/sessions/test_session_sharing.py | 2 - backend/tests/settings/test_alerts.py | 10 -- backend/tests/users/fixtures.py | 7 +- backend/tests/users/test_tokens.py | 7 +- frontend/.storybook/preview.ts | 2 - frontend/package-lock.json | 115 ++++++++++---- frontend/package.json | 1 - .../auth/auth-guard/auth-guard.service.ts | 3 +- .../auth-redirect/auth-redirect.component.ts | 47 +++++- .../app/general/auth/auth/auth.component.html | 6 +- .../app/general/auth/auth/auth.component.ts | 8 +- .../auth/http-interceptor/auth.interceptor.ts | 27 +--- .../app/general/header/header.component.html | 51 +++---- .../app/general/header/header.component.ts | 2 - .../nav-bar-menu/nav-bar-menu.component.html | 103 ++++++------- .../nav-bar-menu/nav-bar-menu.component.ts | 3 +- .../create-project.component.spec.ts | 2 - .../src/app/services/auth/auth.service.ts | 113 ++++---------- .../src/app/services/user/user.service.ts | 1 + .../app/sessions/service/session.service.ts | 32 +--- .../session/session-viewer.service.ts | 8 + .../connection-dialog.component.ts | 5 +- .../connection-dialog.stories.ts | 3 - frontend/src/main.ts | 2 - helm/config/backend.yaml | 40 ++--- helm/values.yaml | 56 +++---- 59 files changed, 953 insertions(+), 1155 deletions(-) create mode 100644 backend/capellacollab/core/authentication/api_key_cookie.py create mode 100644 backend/capellacollab/core/authentication/flow.py delete mode 100644 backend/capellacollab/core/authentication/jwt_bearer.py create mode 100644 backend/capellacollab/core/authentication/models.py delete mode 100644 backend/capellacollab/core/authentication/provider/__init__.py delete mode 100644 backend/capellacollab/core/authentication/provider/azure/__init__.py delete mode 100644 backend/capellacollab/core/authentication/provider/azure/__main__.py delete mode 100644 backend/capellacollab/core/authentication/provider/azure/keystore.py delete mode 100644 backend/capellacollab/core/authentication/provider/azure/routes.py delete mode 100644 backend/capellacollab/core/authentication/provider/models.py delete mode 100644 backend/capellacollab/core/authentication/provider/oauth/__init__.py delete mode 100644 backend/capellacollab/core/authentication/provider/oauth/__main__.py delete mode 100644 backend/capellacollab/core/authentication/provider/oauth/flow.py delete mode 100644 backend/capellacollab/core/authentication/provider/oauth/keystore.py delete mode 100644 backend/capellacollab/core/authentication/provider/oauth/routes.py create mode 100644 backend/capellacollab/core/authentication/routes.py delete mode 100644 backend/capellacollab/core/authentication/schemas.py create mode 100644 backend/tests/core/conftest.py create mode 100644 backend/tests/core/test_auth.py diff --git a/backend/capellacollab/__main__.py b/backend/capellacollab/__main__.py index 59da038c7..4d03bde11 100644 --- a/backend/capellacollab/__main__.py +++ b/backend/capellacollab/__main__.py @@ -15,6 +15,7 @@ import capellacollab.projects.toolmodels.backups.runs.interface as pipeline_runs_interface import capellacollab.sessions.metrics as sessions_metrics import capellacollab.settings.modelsources.t4c.metrics as t4c_metrics +from capellacollab import core # This import statement is required and should not be removed! (Alembic will not work otherwise) from capellacollab.config import config @@ -40,6 +41,13 @@ handlers=[stream_handler, timed_rotating_file_handler], ) +ALLOW_ORIGINS = ( + [f"{config.general.scheme}//{config.general.host}:{config.general.port}"] + + ["http://localhost:4200"] + if core.DEVELOPMENT_MODE + else [] +) + async def startup(): migration.migrate_db(engine, config.database.url) @@ -49,7 +57,6 @@ async def startup(): operators.get_operator() logging.getLogger("uvicorn.access").disabled = True - logging.getLogger("requests_oauthlib.oauth2_session").setLevel("INFO") logging.getLogger("kubernetes.client.rest").setLevel("INFO") @@ -71,7 +78,7 @@ async def shutdown(): middleware=[ middleware.Middleware( cors.CORSMiddleware, - allow_origins=["*"], + allow_origins=ALLOW_ORIGINS, allow_credentials=True, allow_methods=["POST", "GET", "OPTIONS", "DELETE", "PUT", "PATCH"], allow_headers=["*"], @@ -110,7 +117,7 @@ async def handle_exceptions(request: fastapi.Request, exc: Exception): """ cors_middleware = cors.CORSMiddleware( app=app, - allow_origins=["*"], + allow_origins=ALLOW_ORIGINS, allow_credentials=True, allow_methods=["POST", "GET", "OPTIONS", "DELETE", "PUT", "PATCH"], allow_headers=["*"], diff --git a/backend/capellacollab/config/models.py b/backend/capellacollab/config/models.py index de6047ed9..3410ec593 100644 --- a/backend/capellacollab/config/models.py +++ b/backend/capellacollab/config/models.py @@ -225,19 +225,12 @@ class AuthOauthClientConfig(BaseConfig): id: str = pydantic.Field( default="default", description="The authentication provider client ID." ) - secret: str | None = pydantic.Field( + secret: str = pydantic.Field( default=None, description="The authentication provider client secret." ) class AuthOauthEndpointsConfig(BaseConfig): - token_issuance: str | None = pydantic.Field( - default=None, - description=( - "The URL of the token issuance endpoint. " - "If not set, the URL is read from the well-known endpoint." - ), - ) authorization: str | None = pydantic.Field( default=None, description=( @@ -245,7 +238,7 @@ class AuthOauthEndpointsConfig(BaseConfig): "If not set, the URL is read from the well-known endpoint." ), ) - well_known: str | None = pydantic.Field( + well_known: str = pydantic.Field( default="http://localhost:8083/default/.well-known/openid-configuration", description="The URL of the OpenID Connect discovery document.", examples=[ @@ -254,22 +247,6 @@ class AuthOauthEndpointsConfig(BaseConfig): ) -class AuthOauthConfig(BaseConfig): - endpoints: AuthOauthEndpointsConfig = AuthOauthEndpointsConfig() - audience: str = pydantic.Field(default="default") - scopes: list[str] | None = pydantic.Field( - default=["openid"], - description="List of scopes that application neeeds to access the required attributes.", - ) - client: AuthOauthClientConfig = AuthOauthClientConfig() - redirect_uri: str = pydantic.Field( - default="http://localhost:4200/oauth2/callback", - description="The URI to which the user is redirected after authentication.", - examples=["http://localhost:4200/oauth2/callback"], - alias="redirectURI", - ) - - class JWTConfig(BaseConfig): username_claim: str = pydantic.Field( default="sub", @@ -278,34 +255,25 @@ class JWTConfig(BaseConfig): ) -class AzureClientConfig(BaseConfig): - id: str - secret: str | None = None - - -class AzureConfig(BaseConfig): - authorization_endpoint: str - client: AzureClientConfig - - class GeneralAuthenticationConfig(BaseConfig): jwt: JWTConfig = JWTConfig() -class OAuthAuthenticationConfig(GeneralAuthenticationConfig): - provider: t.Literal["oauth"] = pydantic.Field( - default="oauth", - description="Indicates the use of OAuth for authentication, not to be changed.", +class AuthenticationConfig(GeneralAuthenticationConfig): + endpoints: AuthOauthEndpointsConfig = AuthOauthEndpointsConfig() + audience: str = pydantic.Field(default="default") + issuer: str = pydantic.Field(default="http://localhost:8083/default") + scopes: list[str] = pydantic.Field( + default=["openid", "offline_access"], + description="List of scopes that application neeeds to access the required attributes.", ) - oauth: AuthOauthConfig = AuthOauthConfig() - - -class AzureAuthenticationConfig(GeneralAuthenticationConfig): - provider: t.Literal["azure"] = pydantic.Field( - default="azure", - description="Indicates the use of Azure AD for authentication, not to be changed.", + client: AuthOauthClientConfig = AuthOauthClientConfig() + redirect_uri: str = pydantic.Field( + default="http://localhost:4200/oauth2/callback", + description="The URI to which the user is redirected after authentication.", + examples=["http://localhost:4200/oauth2/callback"], + alias="redirectURI", ) - azure: AzureConfig class PipelineConfig(BaseConfig): @@ -374,9 +342,7 @@ class AppConfig(BaseConfig): k8s: K8sConfig = K8sConfig(context="k3d-collab-cluster") general: GeneralConfig = GeneralConfig() extensions: ExtensionsConfig = ExtensionsConfig() - authentication: OAuthAuthenticationConfig | AzureAuthenticationConfig = ( - OAuthAuthenticationConfig() - ) + authentication: AuthenticationConfig = AuthenticationConfig() prometheus: PrometheusConfig = PrometheusConfig() database: DatabaseConfig = DatabaseConfig() initial: InitialConfig = InitialConfig() diff --git a/backend/capellacollab/core/authentication/__init__.py b/backend/capellacollab/core/authentication/__init__.py index 1fe88f71e..04412280d 100644 --- a/backend/capellacollab/core/authentication/__init__.py +++ b/backend/capellacollab/core/authentication/__init__.py @@ -1,23 +1,2 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 - - -from importlib import metadata - -from capellacollab.config import config - - -def get_authentication_entrypoint(): - try: - ep = next( - i - for i in metadata.entry_points().select( - group="capellacollab.authentication.providers" - ) - if i.name == config.authentication.provider - ) - return ep - except StopIteration: - raise ValueError( - "Unknown authentication provider " + config.authentication.provider - ) from None diff --git a/backend/capellacollab/core/authentication/api_key_cookie.py b/backend/capellacollab/core/authentication/api_key_cookie.py new file mode 100644 index 000000000..d79dbb0b3 --- /dev/null +++ b/backend/capellacollab/core/authentication/api_key_cookie.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import logging +import typing as t + +import fastapi +import jwt +from fastapi import security +from jwt import exceptions as jwt_exceptions + +from capellacollab.config import config + +from . import exceptions, flow + +log = logging.getLogger(__name__) + + +class JWTConfigBorg: + _shared_state: dict[str, str] = {} + + def __init__(self) -> None: + self.__dict__ = self._shared_state + + if not hasattr(self, "_jwks_client"): + self.jwks_client = jwt.PyJWKClient( + uri=flow.get_auth_endpoints()["jwks_uri"] + ) + + if not hasattr(self, "_supported_signing_algorithms"): + self.supported_signing_algorithms = ( + flow.get_supported_signing_algorithms() + ) + + +class JWTAPIKeyCookie(security.APIKeyCookie): + def __init__(self): + super().__init__(name="id_token", auto_error=True) + self.jwt_config = JWTConfigBorg() + + async def __call__(self, request: fastapi.Request) -> str: + token: str | None = await super().__call__(request) + + if not token: + raise exceptions.UnauthenticatedError() + + token_decoded = self.validate_token(token) + return self.get_username(token_decoded) + + def get_username(self, token_decoded: dict[str, str]) -> str: + return token_decoded[config.authentication.jwt.username_claim].strip() + + def validate_token(self, token: str) -> dict[str, t.Any]: + try: + signing_key = self.jwt_config.jwks_client.get_signing_key_from_jwt( + token + ) + + return jwt.decode( + jwt=token, + key=signing_key.key, + algorithms=self.jwt_config.supported_signing_algorithms, + audience=config.authentication.client.id, + issuer=config.authentication.issuer, + options={ + "verify_exp": True, + "verify_iat": True, + "verify_nbf": True, + }, + ) + except jwt_exceptions.ExpiredSignatureError: + raise exceptions.TokenSignatureExpired() + except jwt_exceptions.PyJWTError: + log.exception("JWT validation failed", exc_info=True) + raise exceptions.JWTValidationFailed() diff --git a/backend/capellacollab/core/authentication/basic_auth.py b/backend/capellacollab/core/authentication/basic_auth.py index 39a29931d..c47f16a45 100644 --- a/backend/capellacollab/core/authentication/basic_auth.py +++ b/backend/capellacollab/core/authentication/basic_auth.py @@ -17,16 +17,15 @@ class HTTPBasicAuth(security.HTTPBasic): - async def __call__( # type: ignore - self, request: fastapi.Request - ) -> str | None: + def __init__(self): + super().__init__(auto_error=True) + + async def __call__(self, request: fastapi.Request) -> str: # type: ignore credentials: security.HTTPBasicCredentials | None = ( await super().__call__(request) ) if not credentials: - if self.auto_error: - raise exceptions.UnauthenticatedError() - return None + raise exceptions.UnauthenticatedError() with database.SessionLocal() as session: user = user_crud.get_user_by_name(session, credentials.username) db_token = ( @@ -38,15 +37,11 @@ async def __call__( # type: ignore ) if not db_token: logger.info("Token invalid for user %s", credentials.username) - if self.auto_error: - raise exceptions.InvalidPersonalAccessTokenError() - return None + raise exceptions.InvalidPersonalAccessTokenError() if db_token.expiration_date < datetime.date.today(): logger.info("Token expired for user %s", credentials.username) - if self.auto_error: - raise exceptions.PersonalAccessTokenExpired() - return None + raise exceptions.PersonalAccessTokenExpired() return self.get_username(credentials) def get_username(self, credentials: security.HTTPBasicCredentials) -> str: diff --git a/backend/capellacollab/core/authentication/exceptions.py b/backend/capellacollab/core/authentication/exceptions.py index fd1c43b52..d511c25e3 100644 --- a/backend/capellacollab/core/authentication/exceptions.py +++ b/backend/capellacollab/core/authentication/exceptions.py @@ -56,7 +56,7 @@ def __init__(self, scheme: str): "Use 'basic' or 'bearer' instead" ), err_code="UNKNOWN_SCHEME", - headers={"WWW-Authenticate": "Bearer, Basic"}, + headers={"WWW-Authenticate": "Basic, Cookie"}, ) @@ -67,7 +67,7 @@ def __init__(self): title="Token signature expired", reason="The Signature of the token is expired. Please refresh the token or request a new access token.", err_code="TOKEN_SIGNATURE_EXPIRED", - headers={"WWW-Authenticate": "Bearer, Basic"}, + headers={"WWW-Authenticate": "Basic, Cookie"}, ) @@ -78,7 +78,7 @@ def __init__(self): title="Refresh token signature expired", reason="The Signature of the refresh token is expired. Please request a new access token.", err_code="REFRESH_TOKEN_EXPIRED", - headers={"WWW-Authenticate": "Bearer, Basic"}, + headers={"WWW-Authenticate": "Basic, Cookie"}, ) @@ -109,7 +109,7 @@ def __init__(self): title="Unauthenticated", reason="Not authenticated", err_code="UNAUTHENTICATED", - headers={"WWW-Authenticate": "Bearer, Basic"}, + headers={"WWW-Authenticate": "Basic, Cookie"}, ) @@ -120,7 +120,27 @@ def __init__(self): title="Personal access token not valid.", reason="The used token is not valid.", err_code="BASIC_TOKEN_INVALID", - headers={"WWW-Authenticate": "Bearer, Basic"}, + headers={"WWW-Authenticate": "Basic, Cookie"}, + ) + + +class NonceMismatchError(core_exceptions.BaseError): + def __init__(self): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + title="The nonce values do not match.", + reason="The nonce value provided in the identity token does not match the generated nonce value.", + err_code="NONCE_VALUE_MISMATCH", + ) + + +class RefreshTokenCookieMissingError(core_exceptions.BaseError): + def __init__(self): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + title="No refresh token provided.", + reason="There was no refresh token cookie provided", + err_code="NO_REFRESH_TOKEN_COOKIE", ) @@ -134,5 +154,5 @@ def __init__(self): "Please request a new access token." ), err_code="PAT_EXPIRED", - headers={"WWW-Authenticate": "Bearer, Basic"}, + headers={"WWW-Authenticate": "Basic, Cookie"}, ) diff --git a/backend/capellacollab/core/authentication/flow.py b/backend/capellacollab/core/authentication/flow.py new file mode 100644 index 000000000..d08584eed --- /dev/null +++ b/backend/capellacollab/core/authentication/flow.py @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import logging +import typing as t + +import requests +from oauthlib import common, oauth2 + +from capellacollab.config import config + +from . import exceptions + +CODE_CHALLENGE_METHOD = "S256" + +logger = logging.getLogger(__name__) + +auth_config = config.authentication +web_client = oauth2.WebApplicationClient(client_id=auth_config.client.id) + + +class AuthEndpoints(t.TypedDict): + authorization_endpoint: str + token_endpoint: str + jwks_uri: str + + +def get_authorization_url_with_parameters() -> t.Tuple[str, str, str, str]: + state = common.generate_token() + nonce = common.generate_nonce() + code_verifier = web_client.create_code_verifier(length=43) + code_challenge = web_client.create_code_challenge( + code_verifier, CODE_CHALLENGE_METHOD + ) + + auth_url = web_client.prepare_request_uri( + uri=get_auth_endpoints()["authorization_endpoint"], + redirect_uri=auth_config.redirect_uri, + scope=auth_config.scopes, + state=state, + nonce=nonce, + code_challenge=code_challenge, + code_challenge_method=CODE_CHALLENGE_METHOD, + ) + + return (auth_url, state, nonce, code_verifier) + + +def exchange_code_for_tokens( + authorization_code: str, code_verifier: str +) -> dict[str, t.Any]: + token_request_body = web_client.prepare_request_body( + code=authorization_code, + redirect_uri=auth_config.redirect_uri, + code_verifier=code_verifier, + client_secret=auth_config.client.secret, + ) + + r = requests.post( + url=get_auth_endpoints()["token_endpoint"], + data=dict(common.urldecode(token_request_body)), + timeout=config.requests.timeout, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + + return web_client.parse_request_body_response(r.text) + + +def refresh_token(_refresh_token: str) -> dict[str, t.Any]: + try: + refresh_request_body = web_client.prepare_refresh_body( + refresh_token=_refresh_token, + scope=auth_config.scopes, + client_id=auth_config.client.id, + client_secret=auth_config.client.secret, + ) + + r = requests.post( + url=get_auth_endpoints()["token_endpoint"], + data=dict(common.urldecode(refresh_request_body)), + timeout=config.requests.timeout, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + + return web_client.parse_request_body_response( + r.text, scope=auth_config.scopes + ) + except Exception as e: + logger.debug("Could not refresh token because of exception %s", str(e)) + raise exceptions.RefreshTokenSignatureExpired() + + +def get_auth_endpoints() -> AuthEndpoints: + well_known_req = requests.get( + auth_config.endpoints.well_known, timeout=config.requests.timeout + ) + well_known_req.raise_for_status() + + resp = well_known_req.json() + + authorization_endpoint = resp["authorization_endpoint"] + if auth_config.endpoints.authorization: + authorization_endpoint = auth_config.endpoints.authorization + + return AuthEndpoints( + authorization_endpoint=authorization_endpoint, + token_endpoint=resp["token_endpoint"], + jwks_uri=resp["jwks_uri"], + ) + + +def get_supported_signing_algorithms() -> list[str]: + well_known_req = requests.get( + auth_config.endpoints.well_known, timeout=config.requests.timeout + ) + well_known_req.raise_for_status() + + resp = well_known_req.json() + + return resp["id_token_signing_alg_values_supported"] diff --git a/backend/capellacollab/core/authentication/injectables.py b/backend/capellacollab/core/authentication/injectables.py index 1d3d566dc..c4a0edcaf 100644 --- a/backend/capellacollab/core/authentication/injectables.py +++ b/backend/capellacollab/core/authentication/injectables.py @@ -13,7 +13,7 @@ from sqlalchemy import orm from capellacollab.core import database -from capellacollab.core.authentication import basic_auth, jwt_bearer +from capellacollab.core.authentication import api_key_cookie, basic_auth from capellacollab.projects import crud as projects_crud from capellacollab.projects import exceptions as projects_exceptions from capellacollab.projects import models as projects_models @@ -59,32 +59,20 @@ class OpenAPIPersonalAccessToken(OpenAPIFakeBase): __hash__ = OpenAPIFakeBase.__hash__ -@dataclasses.dataclass() -class OpenAPIBearerToken(OpenAPIFakeBase): - """Displays the JWT Bearer token as authentication method in the OpenAPI docs""" - - model = openapi_models.HTTPBase( - scheme="bearer", - ) - scheme_name = "JWTBearer" - - __hash__ = OpenAPIFakeBase.__hash__ - - async def get_username( request: fastapi.Request, _unused1=fastapi.Depends(OpenAPIPersonalAccessToken()), - _unused2=fastapi.Depends(OpenAPIBearerToken()), ) -> str: + if request.cookies.get("id_token"): + username = await api_key_cookie.JWTAPIKeyCookie()(request) + return username + authorization = request.headers.get("Authorization") scheme, _ = security_utils.get_authorization_scheme_param(authorization) - username = None match scheme.lower(): case "basic": username = await basic_auth.HTTPBasicAuth()(request) - case "bearer": - username = await jwt_bearer.JWTBearer()(request) case "": raise exceptions.UnauthenticatedError() case _: diff --git a/backend/capellacollab/core/authentication/jwt_bearer.py b/backend/capellacollab/core/authentication/jwt_bearer.py deleted file mode 100644 index 922de4724..000000000 --- a/backend/capellacollab/core/authentication/jwt_bearer.py +++ /dev/null @@ -1,78 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - -import importlib -import logging -import typing as t - -import fastapi -from fastapi import security -from jose import exceptions as jwt_exceptions -from jose import jwt - -import capellacollab.users.crud as users_crud -from capellacollab.config import config -from capellacollab.core import database -from capellacollab.events import crud as events_crud - -from . import exceptions, get_authentication_entrypoint - -log = logging.getLogger(__name__) -ep = get_authentication_entrypoint() -ep_main = importlib.import_module(".__main__", ep.module) - - -class JWTBearer(security.HTTPBearer): - def __init__(self, auto_error: bool = True): - super().__init__(auto_error=auto_error) - - async def __call__( # type: ignore - self, request: fastapi.Request - ) -> str | None: - credentials: security.HTTPAuthorizationCredentials | None = ( - await super().__call__(request) - ) - - if not credentials or credentials.scheme != "Bearer": - if self.auto_error: - raise exceptions.UnauthenticatedError() - return None - if token_decoded := self.validate_token(credentials.credentials): - self.initialize_user(token_decoded) - return self.get_username(token_decoded) - if self.auto_error: - raise exceptions.UnauthenticatedError() - return None - - def get_username(self, token_decoded: dict[str, str]) -> str: - return token_decoded[config.authentication.jwt.username_claim].strip() - - def initialize_user(self, token_decoded: dict[str, str]): - with database.SessionLocal() as session: - username: str = self.get_username(token_decoded) - if not users_crud.get_user_by_name(session, username): - created_user = users_crud.create_user(session, username) - users_crud.update_last_login(session, created_user) - events_crud.create_user_creation_event(session, created_user) - - def validate_token(self, token: str) -> dict[str, t.Any] | None: - try: - jwt_cfg = ep_main.get_jwk_cfg(token) - except Exception: - log.exception( - "Couldn't determine JWK configuration", exc_info=True - ) - if self.auto_error: - raise exceptions.JWTInvalidToken() - return None - try: - return jwt.decode(token, **jwt_cfg) - except jwt_exceptions.ExpiredSignatureError: - if self.auto_error: - raise exceptions.TokenSignatureExpired() - return None - except (jwt_exceptions.JWTError, jwt_exceptions.JWTClaimsError): - log.exception("JWT validation failed", exc_info=True) - if self.auto_error: - raise exceptions.JWTValidationFailed() - return None diff --git a/backend/capellacollab/core/authentication/models.py b/backend/capellacollab/core/authentication/models.py new file mode 100644 index 000000000..2a4d936ef --- /dev/null +++ b/backend/capellacollab/core/authentication/models.py @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + + +from capellacollab.core import pydantic as core_pydantic + + +class TokenRequest(core_pydantic.BaseModel): + code: str + nonce: str + code_verifier: str diff --git a/backend/capellacollab/core/authentication/provider/__init__.py b/backend/capellacollab/core/authentication/provider/__init__.py deleted file mode 100644 index 04412280d..000000000 --- a/backend/capellacollab/core/authentication/provider/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 diff --git a/backend/capellacollab/core/authentication/provider/azure/__init__.py b/backend/capellacollab/core/authentication/provider/azure/__init__.py deleted file mode 100644 index 04412280d..000000000 --- a/backend/capellacollab/core/authentication/provider/azure/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 diff --git a/backend/capellacollab/core/authentication/provider/azure/__main__.py b/backend/capellacollab/core/authentication/provider/azure/__main__.py deleted file mode 100644 index 36967a8c8..000000000 --- a/backend/capellacollab/core/authentication/provider/azure/__main__.py +++ /dev/null @@ -1,24 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - -import typing as t - -from capellacollab.config import config -from capellacollab.config import models as config_models - -from . import keystore - -# Our "singleton" key store: -KeyStore = keystore._KeyStore(jwks_uri=keystore.get_jwks_uri_for_azure_ad()) - -assert isinstance( - config.authentication, config_models.AzureAuthenticationConfig -) -cfg = config.authentication.azure - - -def get_jwk_cfg(token: str) -> dict[str, t.Any]: - return { - "audience": cfg.client.id if cfg else None, - "key": KeyStore.key_for_token(token).model_dump(), - } diff --git a/backend/capellacollab/core/authentication/provider/azure/keystore.py b/backend/capellacollab/core/authentication/provider/azure/keystore.py deleted file mode 100644 index 36bbac429..000000000 --- a/backend/capellacollab/core/authentication/provider/azure/keystore.py +++ /dev/null @@ -1,109 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - -# pylint: skip-file - - -import logging -import time -import typing as t - -import requests -from jose import jwt - -from capellacollab.config import config -from capellacollab.config import models as config_models - -from .. import models as provider_models - -log = logging.getLogger(__name__) -assert isinstance( - config.authentication, config_models.AzureAuthenticationConfig -) -cfg = config.authentication.azure - - -# Copied and adapted from https://github.com/marpaia/jwks/blob/master/jwks/jwks.py: - - -class _KeyStore: - def __init__( - self, - *, - jwks_uri: str, - algorithms: list[str] | None = None, - key_refresh_interval=3600, - ): - if not algorithms: - algorithms = ["RS256"] - - self.jwks_uri = jwks_uri - self.algorithms = algorithms - self.public_keys: dict[str, provider_models.JSONWebKey] = {} - self.key_refresh_interval = key_refresh_interval - self.public_keys_last_refreshed: float = 0 - self.refresh_keys() - - def keys_need_refresh(self) -> bool: - return ( - time.time() - self.public_keys_last_refreshed - ) > self.key_refresh_interval - - def refresh_keys(self) -> None: - try: - resp = requests.get(self.jwks_uri, timeout=config.requests.timeout) - except Exception: - log.error("Could not retrieve JWKS data from %s", self.jwks_uri) - return - jwks = provider_models.JSONWebKeySet.parse_raw(resp.text) - self.public_keys_last_refreshed = time.time() - self.public_keys.clear() - for key in jwks.keys: - self.public_keys[key.kid] = key - - def key_for_token( - self, token: str, *, in_retry: int = 0 - ) -> provider_models.JSONWebKey: - # Before we do anything, the validation keys may need to be refreshed. - # If so, refresh them. - if self.keys_need_refresh(): - self.refresh_keys() - - # Try to extract the claims from the token so that we can use the key ID - # to determine which key we should use to validate the token. - try: - unverified_claims = jwt.get_unverified_header(token) - except Exception: - raise provider_models.InvalidTokenError( - "Unable to parse key ID from token" - ) - - # See if we have the key identified by this key ID. - try: - return self.public_keys[unverified_claims["kid"]] - except KeyError: - # If we don't have this key and this is the first attempt (ie: we - # haven't refreshed keys yet), then try to refresh the keys and try - # again. - if in_retry: - raise provider_models.KeyIDNotFoundError() - self.refresh_keys() - return self.key_for_token(token, in_retry=1) - - -def get_jwks_uri_for_azure_ad( - authorization_endpoint=cfg.authorization_endpoint, -): - discoveryEndpoint = ( - f"{authorization_endpoint}/v2.0/.well-known/openid-configuration" - ) - - openid_config = requests.get( - discoveryEndpoint, - timeout=config.requests.timeout, - ).json() - return openid_config["jwks_uri"] - - -# Our "singleton" key store: -KeyStore = _KeyStore(jwks_uri=get_jwks_uri_for_azure_ad()) diff --git a/backend/capellacollab/core/authentication/provider/azure/routes.py b/backend/capellacollab/core/authentication/provider/azure/routes.py deleted file mode 100644 index 90888934a..000000000 --- a/backend/capellacollab/core/authentication/provider/azure/routes.py +++ /dev/null @@ -1,112 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - - -import secrets -from functools import lru_cache - -import fastapi -from cachetools import TTLCache -from msal import ConfidentialClientApplication -from sqlalchemy import orm - -import capellacollab.users.crud as users_crud -from capellacollab.config import config -from capellacollab.config import models as config_models -from capellacollab.core import database -from capellacollab.core.authentication import injectables as auth_injectables -from capellacollab.core.authentication import jwt_bearer -from capellacollab.core.authentication.schemas import ( - RefreshTokenRequest, - TokenRequest, -) -from capellacollab.users.models import Role - -router = fastapi.APIRouter() - -assert isinstance( - config.authentication, config_models.AzureAuthenticationConfig -) - -cfg = config.authentication.azure - - -@lru_cache -def ad_session(): - return ConfidentialClientApplication( - cfg.client.id, - client_credential=cfg.client.secret, - authority=cfg.authorization_endpoint, - ) - - -# Make this a cache: -global_session_data = TTLCache(maxsize=128, ttl=3600) - - -@router.get("", name="Get redirect URL for azure authentication") -async def get_redirect_url(): - state = secrets.token_hex(32) - assert state not in global_session_data - session_data = ad_session().initiate_auth_code_flow(scopes=[], state=state) - global_session_data[session_data["state"]] = session_data - return { - "auth_url": session_data["auth_uri"], - "state": session_data["state"], - } - - -@router.post("/tokens", name="Create access_token") -async def api_get_token( - body: TokenRequest, db: orm.Session = fastapi.Depends(database.get_db) -): - auth_data = global_session_data[body.state] - del global_session_data[body.state] - token = ad_session().acquire_token_by_auth_code_flow( - auth_data, body.model_dump(), scopes=[] - ) - access_token = token["id_token"] - - validated_token = jwt_bearer.JWTBearer().validate_token(access_token) - assert validated_token - - username = jwt_bearer.JWTBearer().get_username(validated_token) - - if user := users_crud.get_user_by_name(db, username): - users_crud.update_last_login(db, user) - - # *Sigh* This is microsoft again. Instead of the access_token, we should use id_token :/ - # https://stackoverflow.com/questions/63195081/how-to-validate-a-jwt-from-azuread-in-python - return { - "access_token": access_token, - "refresh_token": token["refresh_token"], - "token_type": token["token_type"], - } - - -@router.put("/tokens", name="Refresh the access_token") -async def api_refresh_token(body: RefreshTokenRequest): - return ad_session().acquire_token_by_refresh_token( - body.refresh_token, scopes=[] - ) - - -@router.delete("/tokens", name="Invalidate the token (log out)") -async def logout(username: str = fastapi.Depends(jwt_bearer.JWTBearer())): - for account in ad_session().get_accounts(): - if account["username"] == username: - return ad_session().remove_account(account) - return None - - -@router.get("/tokens", name="Validate the token") -async def validate_token( - scope: Role | None, - username: str = fastapi.Depends(jwt_bearer.JWTBearer()), - db: orm.Session = fastapi.Depends(database.get_db), -): - if scope and scope.ADMIN: - auth_injectables.RoleVerification(required_role=Role.ADMIN)( - username, db - ) - return username diff --git a/backend/capellacollab/core/authentication/provider/models.py b/backend/capellacollab/core/authentication/provider/models.py deleted file mode 100644 index ed319e460..000000000 --- a/backend/capellacollab/core/authentication/provider/models.py +++ /dev/null @@ -1,28 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - - -from capellacollab.core import pydantic as core_pydantic - - -class JSONWebKey(core_pydantic.BaseModel): - # alg: str - kty: str - use: str - n: str - e: str - kid: str - x5t: str | None = None - x5c: list[str] | None = None - - -class JSONWebKeySet(core_pydantic.BaseModel): - keys: list[JSONWebKey] - - -class InvalidTokenError(Exception): - pass - - -class KeyIDNotFoundError(Exception): - pass diff --git a/backend/capellacollab/core/authentication/provider/oauth/__init__.py b/backend/capellacollab/core/authentication/provider/oauth/__init__.py deleted file mode 100644 index 04412280d..000000000 --- a/backend/capellacollab/core/authentication/provider/oauth/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 diff --git a/backend/capellacollab/core/authentication/provider/oauth/__main__.py b/backend/capellacollab/core/authentication/provider/oauth/__main__.py deleted file mode 100644 index 4f4d7272d..000000000 --- a/backend/capellacollab/core/authentication/provider/oauth/__main__.py +++ /dev/null @@ -1,23 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - - -import typing as t - -from capellacollab.config import config -from capellacollab.config import models as config_models - -from .keystore import KeyStore - -assert isinstance( - config.authentication, config_models.OAuthAuthenticationConfig -) -cfg = config.authentication.oauth - - -def get_jwk_cfg(token: str) -> dict[str, t.Any]: - return { - "algorithms": ["RS256"], - "audience": cfg.audience or cfg.client.id, - "key": KeyStore.key_for_token(token).model_dump(), - } diff --git a/backend/capellacollab/core/authentication/provider/oauth/flow.py b/backend/capellacollab/core/authentication/provider/oauth/flow.py deleted file mode 100644 index 7927d7fb9..000000000 --- a/backend/capellacollab/core/authentication/provider/oauth/flow.py +++ /dev/null @@ -1,88 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - - -import logging -import typing as t - -import requests -from requests_oauthlib import OAuth2Session - -from capellacollab.config import config -from capellacollab.config import models as config_models -from capellacollab.core.authentication import exceptions as auth_exceptions - -assert isinstance( - config.authentication, config_models.OAuthAuthenticationConfig -) -cfg = config.authentication.oauth - -logger = logging.getLogger(__name__) - - -auth_args = {} -if cfg.scopes: - auth_args["scope"] = cfg.scopes - -auth_session = OAuth2Session( - cfg.client.id, redirect_uri=cfg.redirect_uri, **auth_args -) - - -def get_auth_redirect_url() -> dict[str, str]: - auth_url, state = auth_session.authorization_url( - read_well_known()["authorization_endpoint"], - grant_type="authorization_code", - ) - - return {"auth_url": auth_url, "state": state} - - -def get_token(code: str) -> dict[str, t.Any]: - return auth_session.fetch_token( - read_well_known()["token_endpoint"], - code=code, - client_id=cfg.client.id, - client_secret=cfg.client.secret, - ) - - -def refresh_token(_refresh_token: str) -> dict[str, t.Any]: - try: - return auth_session.refresh_token( - read_well_known()["token_endpoint"], - refresh_token=_refresh_token, - client_id=cfg.client.id, - client_secret=cfg.client.secret, - ) - except Exception as e: - logger.debug("Could not refresh token because of exception %s", str(e)) - raise auth_exceptions.RefreshTokenSignatureExpired() - - -def read_well_known() -> dict[str, t.Any]: - authorization_endpoint = None - token_endpoint = None - - if cfg.endpoints.well_known: - r = requests.get( - cfg.endpoints.well_known, - timeout=config.requests.timeout, - ) - r.raise_for_status() - - resp = r.json() - - authorization_endpoint = resp["authorization_endpoint"] - token_endpoint = resp["token_endpoint"] - - if cfg.endpoints.authorization: - authorization_endpoint = cfg.endpoints.authorization - - if cfg.endpoints.token_issuance: - token_endpoint = cfg.endpoints.token_issuance - - return { - "authorization_endpoint": authorization_endpoint, - "token_endpoint": token_endpoint, - } diff --git a/backend/capellacollab/core/authentication/provider/oauth/keystore.py b/backend/capellacollab/core/authentication/provider/oauth/keystore.py deleted file mode 100644 index 3b266c1e1..000000000 --- a/backend/capellacollab/core/authentication/provider/oauth/keystore.py +++ /dev/null @@ -1,104 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - -# pylint: skip-file - - -import logging -import time -import typing as t - -import jwt -import requests - -from capellacollab.config import config -from capellacollab.config import models as config_models -from capellacollab.core.authentication.provider import models - -from .. import models as provider_models - -log = logging.getLogger(__name__) -assert isinstance( - config.authentication, config_models.OAuthAuthenticationConfig -) -cfg = config.authentication.oauth - - -# Copied and adapted from https://github.com/marpaia/jwks/blob/master/jwks/jwks.py: - - -class _KeyStore: - def __init__( - self, - *, - get_jwks_uri: t.Callable[[], str], - algorithms: list[str] | None = None, - key_refresh_interval=3600, - ): - if not algorithms: - algorithms = ["RS256"] - - self.get_jwks_uri = get_jwks_uri - self.jwks_uri = "" - self.algorithms = algorithms - self.public_keys: dict[str, provider_models.JSONWebKey] = {} - self.key_refresh_interval = key_refresh_interval - self.public_keys_last_refreshed: float = 0 - - def keys_need_refresh(self) -> bool: - return ( - time.time() - self.public_keys_last_refreshed - ) > self.key_refresh_interval - - def refresh_keys(self) -> None: - if not self.jwks_uri: - self.jwks_uri = self.get_jwks_uri() - try: - resp = requests.get(self.jwks_uri, timeout=config.requests.timeout) - except Exception: - log.error("Could not retrieve JWKS data from %s", self.jwks_uri) - return - jwks = models.JSONWebKeySet.parse_raw(resp.text) - self.public_keys_last_refreshed = time.time() - self.public_keys.clear() - for key in jwks.keys: - self.public_keys[key.kid] = key - - def key_for_token( - self, token: str, *, in_retry: int = 0 - ) -> provider_models.JSONWebKey: - # Before we do anything, the validation keys may need to be refreshed. - # If so, refresh them. - if self.keys_need_refresh(): - self.refresh_keys() - - # Try to extract the claims from the token so that we can use the key ID - # to determine which key we should use to validate the token. - try: - unverified_claims = jwt.get_unverified_header(token) - except Exception: - raise models.InvalidTokenError("Unable to parse key ID from token") - # See if we have the key identified by this key ID. - - try: - return self.public_keys[unverified_claims["kid"]] - except KeyError: - # If we don't have this key and this is the first attempt (ie: we - # haven't refreshed keys yet), then try to refresh the keys and try - # again. - if in_retry: - raise models.KeyIDNotFoundError() - self.refresh_keys() - return self.key_for_token(token, in_retry=1) - - -def _get_jwks_uri(wellknown_endpoint=cfg.endpoints.well_known): - openid_config = requests.get( - wellknown_endpoint, - timeout=config.requests.timeout, - ).json() - return openid_config["jwks_uri"] - - -# Our "singleton" key store: -KeyStore = _KeyStore(get_jwks_uri=_get_jwks_uri) diff --git a/backend/capellacollab/core/authentication/provider/oauth/routes.py b/backend/capellacollab/core/authentication/provider/oauth/routes.py deleted file mode 100644 index 4f8d00839..000000000 --- a/backend/capellacollab/core/authentication/provider/oauth/routes.py +++ /dev/null @@ -1,65 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - -import fastapi -from sqlalchemy import orm - -import capellacollab.users.crud as users_crud -from capellacollab.core import database -from capellacollab.core.authentication import injectables as auth_injectables -from capellacollab.core.authentication import jwt_bearer -from capellacollab.core.authentication.schemas import ( - RefreshTokenRequest, - TokenRequest, -) -from capellacollab.users.models import Role - -from .flow import get_auth_redirect_url, get_token, refresh_token - -router = fastapi.APIRouter() - - -@router.get("", name="Get redirect URL for OAuth") -async def get_redirect_url(): - return get_auth_redirect_url() - - -@router.post("/tokens", name="Create access_token") -async def api_get_token( - body: TokenRequest, db: orm.Session = fastapi.Depends(database.get_db) -): - token = get_token(body.code) - access_token = token["access_token"] - - validated_token = jwt_bearer.JWTBearer().validate_token(access_token) - assert validated_token - - username = jwt_bearer.JWTBearer().get_username(validated_token) - - if user := users_crud.get_user_by_name(db, username): - users_crud.update_last_login(db, user) - - return token - - -@router.put("/tokens", name="Refresh the access_token") -async def api_refresh_token(body: RefreshTokenRequest): - return refresh_token(body.refresh_token) - - -@router.delete("/tokens", name="Invalidate the token (log out)") -async def logout(): - return None - - -@router.get("/tokens", name="Validate the token") -async def validate_token( - scope: Role | None = None, - username: str = fastapi.Depends(jwt_bearer.JWTBearer()), - db: orm.Session = fastapi.Depends(database.get_db), -): - if scope and scope.ADMIN: - auth_injectables.RoleVerification(required_role=Role.ADMIN)( - username, db - ) - return username diff --git a/backend/capellacollab/core/authentication/routes.py b/backend/capellacollab/core/authentication/routes.py new file mode 100644 index 000000000..e73feb604 --- /dev/null +++ b/backend/capellacollab/core/authentication/routes.py @@ -0,0 +1,142 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import hmac +import typing as t + +import fastapi +from sqlalchemy import orm + +from capellacollab.config import config +from capellacollab.core import database, responses +from capellacollab.core.authentication import injectables as auth_injectables +from capellacollab.events import crud as events_crud +from capellacollab.users import crud as users_crud +from capellacollab.users import models as users_models + +from . import api_key_cookie, exceptions, flow, models + +router = fastapi.APIRouter() + + +@router.get("", name="Get the authorization URL for the OAuth Server") +async def get_redirect_url(response: fastapi.Response) -> dict[str, str]: + auth_url, state, nonce, code_verifier = ( + flow.get_authorization_url_with_parameters() + ) + delete_token_cookies(response) + + return { + "auth_url": auth_url, + "state": state, + "nonce": nonce, + "code_verifier": code_verifier, + } + + +@router.post("/tokens", name="Create the identity token") +async def api_get_token( + token_request: models.TokenRequest, + response: fastapi.Response, + db: orm.Session = fastapi.Depends(database.get_db), +): + tokens = flow.exchange_code_for_tokens( + token_request.code, token_request.code_verifier + ) + + user = validate_id_token(db, tokens["id_token"], token_request.nonce) + + update_token_cookies( + response, tokens["id_token"], tokens.get("refresh_token", None), user + ) + + +@router.put("/tokens", name="Refresh the identity token") +async def api_refresh_token( + response: fastapi.Response, + refresh_token: t.Annotated[str | None, fastapi.Cookie()] = None, + db: orm.Session = fastapi.Depends(database.get_db), +): + if refresh_token is None or refresh_token == "": + raise exceptions.RefreshTokenCookieMissingError() + + tokens = flow.refresh_token(refresh_token) + + user = validate_id_token(db, tokens["id_token"], None) + update_token_cookies( + response, tokens["id_token"], tokens.get("refresh_token", None), user + ) + + +@router.delete("/tokens", name="Remove the token (log out)") +async def logout(response: fastapi.Response): + delete_token_cookies(response) + return None + + +@router.get("/tokens", name="Validate the token") +async def validate_token( + request: fastapi.Request, + scope: users_models.Role | None = None, + db: orm.Session = fastapi.Depends(database.get_db), +): + username = await api_key_cookie.JWTAPIKeyCookie()(request) + if scope and scope.ADMIN: + auth_injectables.RoleVerification( + required_role=users_models.Role.ADMIN + )(username, db) + return username + + +def validate_id_token( + db: orm.Session, id_token: str, nonce: str | None +) -> users_models.DatabaseUser: + validated_id_token = api_key_cookie.JWTAPIKeyCookie().validate_token( + id_token + ) + + if nonce and not hmac.compare_digest(validated_id_token["nonce"], nonce): + raise exceptions.NonceMismatchError() + + if config.authentication.client.id not in validated_id_token["aud"]: + raise exceptions.UnauthenticatedError() + + username = api_key_cookie.JWTAPIKeyCookie().get_username( + validated_id_token + ) + + user = users_crud.get_user_by_name(db, username) + if not user: + user = users_crud.create_user(db, username) + events_crud.create_user_creation_event(db, user) + + users_crud.update_last_login(db, user) + + return user + + +def update_token_cookies( + response: fastapi.Response, + id_token: str, + refresh_token: str | None, + user: users_models.DatabaseUser, +) -> None: + responses.set_secure_cookie(response, "id_token", id_token, "/api/v1") + + if refresh_token: + responses.set_secure_cookie( + response, "refresh_token", refresh_token, "/api/v1" + ) + + if user.role == users_models.Role.ADMIN: + responses.set_secure_cookie(response, "id_token", id_token, "/grafana") + responses.set_secure_cookie( + response, "id_token", id_token, "/prometheus" + ) + + +def delete_token_cookies(response: fastapi.Response): + responses.delete_secure_cookie(response, "id_token", "/api/v1") + responses.delete_secure_cookie(response, "id_token", "/prometheus") + responses.delete_secure_cookie(response, "id_token", "/grafana") + responses.delete_secure_cookie(response, "refresh_token", "/api/v1") diff --git a/backend/capellacollab/core/authentication/schemas.py b/backend/capellacollab/core/authentication/schemas.py deleted file mode 100644 index 3963cb576..000000000 --- a/backend/capellacollab/core/authentication/schemas.py +++ /dev/null @@ -1,14 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - - -from pydantic import BaseModel - - -class TokenRequest(BaseModel): - code: str - state: str - - -class RefreshTokenRequest(BaseModel): - refresh_token: str diff --git a/backend/capellacollab/core/responses.py b/backend/capellacollab/core/responses.py index 0765fa949..aa1688c1c 100644 --- a/backend/capellacollab/core/responses.py +++ b/backend/capellacollab/core/responses.py @@ -1,13 +1,14 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 - +import datetime import os import typing as t import fastapi import pydantic +from capellacollab.config import config from capellacollab.core import pydantic as core_pydantic from capellacollab.projects.users import models as projects_users_models from capellacollab.users import models as users_models @@ -111,6 +112,38 @@ def _translate_exceptions_to_openapi_schema(excs: list[exceptions.BaseError]): } +def set_secure_cookie( + response: fastapi.Response, + key: str, + value: str, + path: str, + expires: datetime.datetime | None = None, +) -> None: + response.set_cookie( + key=key, + value=value, + expires=expires, + path=path, + samesite="strict", + httponly=True, + secure=config.general.scheme == "https", + domain=config.general.host, + ) + + +def delete_secure_cookie( + response: fastapi.Response, key: str, path: str +) -> None: + response.delete_cookie( + key=key, + path=path, + samesite="strict", + httponly=True, + secure=config.general.scheme == "https", + domain=config.general.host, + ) + + class SVGResponse(fastapi.responses.Response): """Custom error class for SVG responses. diff --git a/backend/capellacollab/routes.py b/backend/capellacollab/routes.py index 1095d020c..067d939f8 100644 --- a/backend/capellacollab/routes.py +++ b/backend/capellacollab/routes.py @@ -2,13 +2,12 @@ # SPDX-License-Identifier: Apache-2.0 -import importlib import logging import fastapi -from capellacollab.core import authentication from capellacollab.core import responses as auth_responses +from capellacollab.core.authentication import routes as authentication_routes from capellacollab.events import routes as events_router from capellacollab.health import routes as health_routes from capellacollab.metadata import routes as core_metadata @@ -77,11 +76,4 @@ prefix="/settings", ) -# Load authentication routes -ep = authentication.get_authentication_entrypoint() - -router.include_router( - importlib.import_module(".routes", ep.module).router, - prefix="/authentication", - tags=[ep.name], -) +router.include_router(authentication_routes.router, prefix="/authentication") diff --git a/backend/capellacollab/sessions/models.py b/backend/capellacollab/sessions/models.py index a356b72a6..566811145 100644 --- a/backend/capellacollab/sessions/models.py +++ b/backend/capellacollab/sessions/models.py @@ -125,14 +125,6 @@ class SessionConnectionInformation(core_pydantic.BaseModel): default={}, ) - cookies: dict[str, str] = pydantic.Field( - description=( - "Cookies, which are required to connect to the session. " - "The provided key/value pairs should be set by the frontend." - ), - default={}, - ) - t4c_token: str | None = pydantic.Field( default=None, description="TeamForCapella session token" ) diff --git a/backend/capellacollab/sessions/routes.py b/backend/capellacollab/sessions/routes.py index 54139fbd5..5025d8abc 100644 --- a/backend/capellacollab/sessions/routes.py +++ b/backend/capellacollab/sessions/routes.py @@ -337,6 +337,7 @@ def share_session( ), ) def get_session_connection_information( + response: fastapi.Response, db: orm.Session = fastapi.Depends(database.get_db), session: models.DatabaseSession = fastapi.Depends( injectables.get_existing_session_including_shared @@ -373,10 +374,18 @@ def get_session_connection_information( t4c_token = hook_result["t4c_token"] warnings += hook_result.get("warnings", []) + for c_key, c_value in cookies.items(): + responses.set_secure_cookie( + response=response, + key=c_key, + value=c_value, + path=f"/session/{session.id}", + expires=datetime.datetime.now() + datetime.timedelta(hours=24), + ) + return core_models.PayloadResponseModel( payload=models.SessionConnectionInformation( local_storage=local_storage, - cookies=cookies, redirect_url=redirect_url, t4c_token=t4c_token, ), diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 213658854..f8564665a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -28,13 +28,12 @@ dependencies = [ "cachetools", "fastapi>=0.101.0", "kubernetes", - "msal", "psycopg2-binary>2.9.7", "pydantic>=2.0.0", "python-dateutil", - "python-jose", "python-multipart", - "requests_oauthlib", + "pyjwt[crypto]", + "oauthlib", "sqlalchemy>=2.0.0", "uvicorn[standard]", "python-slugify[unidecode]", @@ -68,10 +67,6 @@ dev = [ "types-lxml", ] -[project.entry-points."capellacollab.authentication.providers"] -oauth = "capellacollab.core.authentication.provider.oauth" -azure = "capellacollab.core.authentication.provider.azure" - [tool.black] line-length = 79 target-version = ["py311"] @@ -117,11 +112,9 @@ module = [ "deepdiff.*", "appdirs.*", "requests.*", - "jose.*", "slugify.*", "cachetools.*", - "requests_oauthlib.*", - "msal.*", + "oauthlib.*", "yaml.*", "fastapi_pagination.*", "aiohttp.*", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e93b7d099..1218ba50d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -12,6 +12,7 @@ import pytest import sqlalchemy import sqlalchemy.exc +from core import conftest as core_conftest from fastapi import testclient from sqlalchemy import engine, orm from testcontainers import postgres @@ -90,8 +91,13 @@ def commit(*args, **kwargs): @pytest.fixture() -def client() -> testclient.TestClient: - return testclient.TestClient(app, headers={"Authorization": "bearer"}) +def client(monkeypatch: pytest.MonkeyPatch) -> testclient.TestClient: + monkeypatch.setattr( + "capellacollab.core.authentication.api_key_cookie.JWTConfigBorg", + core_conftest.MockJWTConfigBorg, + ) + + return testclient.TestClient(app, cookies={"id_token": "any"}) @pytest.fixture(name="logger") diff --git a/backend/tests/core/conftest.py b/backend/tests/core/conftest.py new file mode 100644 index 000000000..5ab7c365f --- /dev/null +++ b/backend/tests/core/conftest.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + + +import pytest +from fastapi import testclient + +from capellacollab.__main__ import app + + +class MockPyJWK: + def __init__(self) -> None: + self.key = "mock-key" + + +class MockJWKSClient: + def get_signing_key_from_jwt(self, token: str): + return MockPyJWK() + + +class MockJWTConfigBorg: + _shared_state: dict[str, str] = {} + + def __init__(self) -> None: + self.__dict__ = self._shared_state + + if not hasattr(self, "_jwks_client"): + self.jwks_client = MockJWKSClient() + + if not hasattr(self, "_supported_signing_algorithms"): + self.supported_signing_algorithms = ["RS256"] + + +@pytest.fixture(name="unauthorized_client") +def fixture_unauthorized_client(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + "capellacollab.core.authentication.api_key_cookie.JWTConfigBorg", + MockJWTConfigBorg, + ) + + return testclient.TestClient(app) diff --git a/backend/tests/core/test_auth.py b/backend/tests/core/test_auth.py new file mode 100644 index 000000000..4ace9293f --- /dev/null +++ b/backend/tests/core/test_auth.py @@ -0,0 +1,130 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + + +from unittest import mock + +import pytest +from fastapi import status, testclient +from sqlalchemy import orm + +from capellacollab.core.authentication import ( + api_key_cookie, + exceptions, + flow, + routes, +) + + +@pytest.fixture(name="mock_auth_endpoints") +def fixture_mock_auth_endpoints(monkeypatch: pytest.MonkeyPatch): + def mock_get_auth_endpoints() -> flow.AuthEndpoints: + return { + "authorization_endpoint": "https://pytest.mock/authorize", + "token_endpoint": "https://pytest.mock/token", + "jwks_uri": "https://pytest.mock/jwks_uri", + } + + monkeypatch.setattr(flow, "get_auth_endpoints", mock_get_auth_endpoints) + + +@pytest.mark.usefixtures("mock_auth_endpoints") +def test_get_authorization_url_with_parameters( + monkeypatch: pytest.MonkeyPatch, +): + mock_generate_token = mock.Mock(return_value="mock-token") + monkeypatch.setattr("oauthlib.common.generate_token", mock_generate_token) + + mock_generate_nonce = mock.Mock(return_value="mock-nonce") + monkeypatch.setattr("oauthlib.common.generate_nonce", mock_generate_nonce) + + monkeypatch.setattr( + flow.auth_config, "redirect_uri", "https://pytest.mock/callback" + ) + monkeypatch.setattr(flow.web_client, "client_id", "mock-clientID") + monkeypatch.setattr(flow.auth_config, "scopes", ["openid", "profile"]) + + auth_url, state, nonce, _ = flow.get_authorization_url_with_parameters() + + assert "https://pytest.mock/authorize" in auth_url + assert "response_type=code" in auth_url + assert "client_id=mock-clientID" in auth_url + assert "redirect_uri=https%3A%2F%2Fpytest.mock%2Fcallback" in auth_url + assert state == "mock-token" + assert "state=mock-token" in auth_url + assert nonce == "mock-nonce" + assert "nonce=mock-nonce" in auth_url + assert "code_challenge" in auth_url + assert f"code_challenge_method={flow.CODE_CHALLENGE_METHOD}" in auth_url + + +def test_get_redirect_url( + unauthorized_client: testclient.TestClient, monkeypatch: pytest.MonkeyPatch +): + def mock_get_authorization_url_with_parameters(): + return ( + "mock-auth_url", + "mock-state", + "mock-nonce", + "mock-code_verifier", + ) + + monkeypatch.setattr( + flow, + "get_authorization_url_with_parameters", + mock_get_authorization_url_with_parameters, + ) + + response = unauthorized_client.get("api/v1/authentication") + json_response = response.json() + + cookies = "".join(response.headers.get_list("set-cookie")) + + assert response.status_code == 200 + assert "auth_url" in json_response + assert "state" in json_response + assert "nonce" in json_response + assert "code_verifier" in json_response + + assert 'id_token=""' in cookies + assert 'refresh_token=""' in cookies + + +def test_missing_refresh_token(unauthorized_client: testclient.TestClient): + response = unauthorized_client.put("api/v1/authentication/tokens") + json_response = response.json() + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert json_response["detail"]["err_code"] == "NO_REFRESH_TOKEN_COOKIE" + + +@pytest.mark.usefixtures("mock_auth_endpoints") +def test_validate_id_token_nonce_mismatch( + db: orm.Session, monkeypatch: pytest.MonkeyPatch +): + mock_jwt_api_cookie = mock.MagicMock() + mock_jwt_api_cookie.return_value.validate_token.return_value = { + "nonce": "mismatch-nonce" + } + + monkeypatch.setattr(api_key_cookie, "JWTAPIKeyCookie", mock_jwt_api_cookie) + + with pytest.raises(exceptions.NonceMismatchError): + routes.validate_id_token(db, "any", "correct-nonce") + + +@pytest.mark.usefixtures("mock_auth_endpoints") +def test_validate_id_token_audience_mismatch( + db: orm.Session, monkeypatch: pytest.MonkeyPatch +): + mock_jwt_api_cookie = mock.MagicMock() + mock_jwt_api_cookie.return_value.validate_token.return_value = { + "nonce": "mock-nonce", + "aud": "mock-audience", + } + + monkeypatch.setattr(api_key_cookie, "JWTAPIKeyCookie", mock_jwt_api_cookie) + monkeypatch.setattr(flow.auth_config.client, "id", "mismatch-clientId") + + with pytest.raises(exceptions.UnauthenticatedError): + routes.validate_id_token(db, "any", "mock-nonce") diff --git a/backend/tests/projects/test_projects_routes.py b/backend/tests/projects/test_projects_routes.py index 12148dfc5..e6e71de87 100644 --- a/backend/tests/projects/test_projects_routes.py +++ b/backend/tests/projects/test_projects_routes.py @@ -18,12 +18,6 @@ from capellacollab.projects import injectables as projects_injectables -def test_get_projects_not_authenticated(client: testclient.TestClient): - response = client.get("/api/v1/projects") - assert response.status_code == 403 - assert response.json() == {"detail": "Not authenticated"} - - def test_get_internal_default_project_as_user( client: testclient.TestClient, db: orm.Session, executor_name: str ): diff --git a/backend/tests/sessions/test_session_hooks.py b/backend/tests/sessions/test_session_hooks.py index 7805a22e4..27565138a 100644 --- a/backend/tests/sessions/test_session_hooks.py +++ b/backend/tests/sessions/test_session_hooks.py @@ -5,6 +5,7 @@ import logging import typing as t +import fastapi import pytest from sqlalchemy import orm @@ -53,6 +54,7 @@ def configuration_hook( session_type: sessions_models.SessionType, connection_method: tools_models.ToolSessionConnectionMethod, provisioning: list[sessions_models.SessionProvisioningRequest], + session_id: str, **kwargs, ) -> hooks_interface.ConfigurationHookResult: self.configuration_hook_counter += 1 @@ -171,7 +173,11 @@ def test_hook_call_during_session_connection( """Test that the session hook is called when connecting to a session""" sessions_routes.get_session_connection_information( - db, session, session.owner, logging.getLogger("test") + fastapi.Response(), + db, + session, + session.owner, + logging.getLogger("test"), ) diff --git a/backend/tests/sessions/test_session_sharing.py b/backend/tests/sessions/test_session_sharing.py index 5a9fe27ed..bb690e28b 100644 --- a/backend/tests/sessions/test_session_sharing.py +++ b/backend/tests/sessions/test_session_sharing.py @@ -3,13 +3,11 @@ import datetime -import fastapi import pytest from fastapi import testclient from sqlalchemy import orm from capellacollab.__main__ import app -from capellacollab.core.authentication import jwt_bearer from capellacollab.sessions import crud as sessions_crud from capellacollab.sessions import models as sessions_models from capellacollab.tools import models as tools_models diff --git a/backend/tests/settings/test_alerts.py b/backend/tests/settings/test_alerts.py index 36eb07431..5420c5afc 100644 --- a/backend/tests/settings/test_alerts.py +++ b/backend/tests/settings/test_alerts.py @@ -35,16 +35,6 @@ def test_get_alerts(client: TestClient, db: orm.Session, executor_name: str): }.items() <= response.json()[0].items() -def test_create_alert_not_authenticated(client: TestClient): - response = client.post( - "/api/v1/notices", - json={"title": "test", "message": "test", "level": "success"}, - ) - - assert response.status_code == 403 - assert response.json() == {"detail": "Not authenticated"} - - def test_create_alert2( client: TestClient, db: orm.Session, executor_name: str ): diff --git a/backend/tests/users/fixtures.py b/backend/tests/users/fixtures.py index 03bf2579e..d26b37789 100644 --- a/backend/tests/users/fixtures.py +++ b/backend/tests/users/fixtures.py @@ -8,9 +8,8 @@ import pytest from sqlalchemy import orm -import capellacollab.users.models as users_models from capellacollab.__main__ import app -from capellacollab.core.authentication.jwt_bearer import JWTBearer +from capellacollab.core.authentication.api_key_cookie import JWTAPIKeyCookie from capellacollab.users import crud as users_crud from capellacollab.users import injectables as users_injectables from capellacollab.users import models as users_models @@ -23,10 +22,10 @@ def fixture_executor_name(monkeypatch: pytest.MonkeyPatch) -> str: name = str(uuid.uuid1()) # pylint: disable=unused-argument - async def bearer_passthrough(self, request: fastapi.Request): + async def cookie_passthrough(self, request: fastapi.Request): return name - monkeypatch.setattr(JWTBearer, "__call__", bearer_passthrough) + monkeypatch.setattr(JWTAPIKeyCookie, "__call__", cookie_passthrough) return name diff --git a/backend/tests/users/test_tokens.py b/backend/tests/users/test_tokens.py index e05b2a418..ba60a1075 100644 --- a/backend/tests/users/test_tokens.py +++ b/backend/tests/users/test_tokens.py @@ -37,9 +37,7 @@ def test_get_user_tokens(client: testclient.TestClient): @responses.activate def test_use_basic_token( - client: testclient.TestClient, - unauthenticated_user: users_models.User, - monkeypatch: pytest.MonkeyPatch, + unauthenticated_user: users_models.User, monkeypatch: pytest.MonkeyPatch ): async def basic_passthrough(self, request: fastapi.Request): return unauthenticated_user.name @@ -47,7 +45,8 @@ async def basic_passthrough(self, request: fastapi.Request): monkeypatch.setattr(HTTPBasicAuth, "__call__", basic_passthrough) token_string = f"{unauthenticated_user.name}:myTestPassword" token = base64.b64encode(token_string.encode("ascii")) - basic_response = client.post( + basic_client = testclient.TestClient(app) + basic_response = basic_client.post( "/api/v1/users/current/tokens", headers={"Authorization": f"basic {token.decode('ascii')}"}, json=POST_TOKEN, diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts index d75b3fd67..c9541dd94 100644 --- a/frontend/.storybook/preview.ts +++ b/frontend/.storybook/preview.ts @@ -11,7 +11,6 @@ import { importProvidersFrom } from '@angular/core'; import { applicationConfig } from '@storybook/angular'; import { ToastrModule } from 'ngx-toastr'; import { RouterModule } from '@angular/router'; -import { CookieModule } from 'ngx-cookie'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { MatDialogRef } from '@angular/material/dialog'; import { DialogRef } from '@angular/cdk/dialog'; @@ -42,7 +41,6 @@ const preview: Preview = { resetTimeoutOnDuplicate: true, includeTitleDuplicates: true, }), - CookieModule.withOptions(), RouterModule.forRoot([]), ), importProvidersFrom(BrowserAnimationsModule), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 36707d2d2..d659932e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,7 +26,6 @@ "highlight.js": "^11.10.0", "http-status-codes": "^2.3.0", "monaco-editor": "^0.50.0", - "ngx-cookie": "^6.0.1", "ngx-skeleton-loader": "^9.0.0", "ngx-toastr": "^19.0.0", "npm": "^10.8.2", @@ -5400,6 +5399,7 @@ "version": "2.2.2", "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz", "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==", + "dev": true, "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", @@ -5415,6 +5415,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -5426,12 +5427,14 @@ "node_modules/@npmcli/agent/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/@npmcli/agent/node_modules/socks-proxy-agent": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz", "integrity": "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw==", + "dev": true, "dependencies": { "agent-base": "^7.1.1", "debug": "^4.3.4", @@ -5456,6 +5459,7 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.8.tgz", "integrity": "sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==", + "dev": true, "dependencies": { "@npmcli/promise-spawn": "^7.0.0", "ini": "^4.1.3", @@ -5475,6 +5479,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, "engines": { "node": ">=16" } @@ -5482,12 +5487,14 @@ "node_modules/@npmcli/git/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/@npmcli/git/node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, "dependencies": { "isexe": "^3.1.1" }, @@ -5549,6 +5556,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.2.0.tgz", "integrity": "sha512-qe/kiqqkW0AGtvBjL8TJKZk/eBBSpnJkUWvHdQ9jM2lKHXRYYJuyNpJPlJw3c8QjC2ow6NZYiLExhUaeJelbxQ==", + "dev": true, "dependencies": { "@npmcli/git": "^5.0.0", "glob": "^10.2.2", @@ -5566,6 +5574,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, "dependencies": { "lru-cache": "^10.0.1" }, @@ -5576,12 +5585,14 @@ "node_modules/@npmcli/package-json/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/@npmcli/promise-spawn": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz", "integrity": "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==", + "dev": true, "dependencies": { "which": "^4.0.0" }, @@ -5593,6 +5604,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, "engines": { "node": ">=16" } @@ -5601,6 +5613,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, "dependencies": { "isexe": "^3.1.1" }, @@ -5615,6 +5628,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-2.0.1.tgz", "integrity": "sha512-YgsR5jCQZhVmTJvjduTOIHph0L73pK8xwMVaDY0PatySqVM9AZj93jpoXYSJqfHFxFkN9dmqTw6OiqExsS3LPw==", + "dev": true, "engines": { "node": "^16.14.0 || >=18.0.0" } @@ -5623,6 +5637,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-8.1.0.tgz", "integrity": "sha512-y7efHHwghQfk28G2z3tlZ67pLG0XdfYbcVG26r7YIXALRsrVQcTq4/tdenSmdOrEsNahIYA/eh8aEVROWGFUDg==", + "dev": true, "dependencies": { "@npmcli/node-gyp": "^3.0.0", "@npmcli/package-json": "^5.0.0", @@ -5639,6 +5654,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, "engines": { "node": ">=16" } @@ -5647,6 +5663,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, "dependencies": { "isexe": "^3.1.1" }, @@ -6153,6 +6170,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.3.2.tgz", "integrity": "sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA==", + "dev": true, "dependencies": { "@sigstore/protobuf-specs": "^0.3.2" }, @@ -6164,6 +6182,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-1.1.0.tgz", "integrity": "sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg==", + "dev": true, "engines": { "node": "^16.14.0 || >=18.0.0" } @@ -6172,6 +6191,7 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.2.tgz", "integrity": "sha512-c6B0ehIWxMI8wiS/bj6rHMPqeFvngFV7cDU/MY+B16P9Z3Mp9k8L93eYZ7BYzSickzuqAQqAq0V956b3Ju6mLw==", + "dev": true, "engines": { "node": "^16.14.0 || >=18.0.0" } @@ -6180,6 +6200,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.3.2.tgz", "integrity": "sha512-5Vz5dPVuunIIvC5vBb0APwo7qKA4G9yM48kPWJT+OEERs40md5GoUR1yedwpekWZ4m0Hhw44m6zU+ObsON+iDA==", + "dev": true, "dependencies": { "@sigstore/bundle": "^2.3.2", "@sigstore/core": "^1.0.0", @@ -6196,6 +6217,7 @@ "version": "18.0.4", "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", "integrity": "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==", + "dev": true, "dependencies": { "@npmcli/fs": "^3.1.0", "fs-minipass": "^3.0.0", @@ -6217,12 +6239,14 @@ "node_modules/@sigstore/sign/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/@sigstore/sign/node_modules/make-fetch-happen": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==", + "dev": true, "dependencies": { "@npmcli/agent": "^2.0.0", "cacache": "^18.0.0", @@ -6245,6 +6269,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, "dependencies": { "minipass": "^7.0.3" }, @@ -6256,6 +6281,7 @@ "version": "2.3.4", "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.4.tgz", "integrity": "sha512-44vtsveTPUpqhm9NCrbU8CWLe3Vck2HO1PNLw7RIajbB7xhtn5RBPm1VNSCMwqGYHhDsBJG8gDF0q4lgydsJvw==", + "dev": true, "dependencies": { "@sigstore/protobuf-specs": "^0.3.2", "tuf-js": "^2.2.1" @@ -6268,6 +6294,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-1.2.1.tgz", "integrity": "sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g==", + "dev": true, "dependencies": { "@sigstore/bundle": "^2.3.2", "@sigstore/core": "^1.1.0", @@ -7320,6 +7347,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, "engines": { "node": "^16.14.0 || >=18.0.0" } @@ -7328,6 +7356,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.1.tgz", "integrity": "sha512-92F7/SFyufn4DXsha9+QfKnN03JGqtMFMXgSHbZOo8JG59WkTni7UzAouNQDf7AuP9OAMxVOPQcqG3sB7w+kkg==", + "dev": true, "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^9.0.4" @@ -7340,6 +7369,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -8268,6 +8298,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -8346,6 +8377,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, "dependencies": { "debug": "^4.3.4" }, @@ -8515,6 +8547,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -8526,6 +8559,7 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -8533,7 +8567,8 @@ "node_modules/ansi-styles/node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true }, "node_modules/any-promise": { "version": "1.3.0", @@ -9495,6 +9530,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -11607,6 +11643,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, "engines": { "node": ">=0.8.0" } @@ -13557,6 +13594,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, "engines": { "node": ">=4" } @@ -14007,6 +14045,7 @@ "version": "7.0.5", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dev": true, "dependencies": { "agent-base": "^7.0.2", "debug": "4" @@ -17273,18 +17312,6 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, - "node_modules/ngx-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ngx-cookie/-/ngx-cookie-6.0.1.tgz", - "integrity": "sha512-TfQPROUaWBOOtPrI6bWqYQelkc7PKwZich5a3bNNgxlH6+v9VgH4D3GoNzTc1Cs2s8dfIw4nGuOHkLiH+FZqiw==", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "@angular/common": ">=12.0.0", - "@angular/core": ">=12.0.0" - } - }, "node_modules/ngx-skeleton-loader": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/ngx-skeleton-loader/-/ngx-skeleton-loader-9.0.0.tgz", @@ -17407,6 +17434,7 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.2.0.tgz", "integrity": "sha512-sp3FonBAaFe4aYTcFdZUn2NYkbP7xroPGYvQmP4Nl5PxamznItBnNCgjrVTKrEfQynInMsJvZrdmqUnysCJ8rw==", + "dev": true, "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", @@ -17456,6 +17484,7 @@ "version": "18.0.4", "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", "integrity": "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==", + "dev": true, "dependencies": { "@npmcli/fs": "^3.1.0", "fs-minipass": "^3.0.0", @@ -17478,6 +17507,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, "engines": { "node": ">=16" } @@ -17485,12 +17515,14 @@ "node_modules/node-gyp/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/node-gyp/node_modules/make-fetch-happen": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==", + "dev": true, "dependencies": { "@npmcli/agent": "^2.0.0", "cacache": "^18.0.0", @@ -17513,6 +17545,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, "dependencies": { "minipass": "^7.0.3" }, @@ -17524,6 +17557,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, "dependencies": { "isexe": "^3.1.1" }, @@ -17550,6 +17584,7 @@ "version": "7.2.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, "dependencies": { "abbrev": "^2.0.0" }, @@ -17564,6 +17599,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", @@ -17577,6 +17613,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, "dependencies": { "lru-cache": "^10.0.1" }, @@ -17587,7 +17624,8 @@ "node_modules/normalize-package-data/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/normalize-path": { "version": "3.0.0", @@ -18532,6 +18570,7 @@ "version": "11.0.2", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.2.tgz", "integrity": "sha512-IGN0IAwmhDJwy13Wc8k+4PEbTPhpJnMtfR53ZbOyjkvmEcLS4nCwp6mvMWjS5sUjeiW3mpx6cHmuhKEu9XmcQw==", + "dev": true, "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", @@ -18546,6 +18585,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, "dependencies": { "lru-cache": "^10.0.1" }, @@ -18556,12 +18596,14 @@ "node_modules/npm-package-arg/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/npm-packlist": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.2.tgz", "integrity": "sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA==", + "dev": true, "dependencies": { "ignore-walk": "^6.0.4" }, @@ -18573,6 +18615,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.1.tgz", "integrity": "sha512-Udm1f0l2nXb3wxDpKjfohwgdFUSV50UVwzEIpDXVsbDMXVIEF81a/i0UhuQbhrPMMmdiq3+YMFLFIRVLs3hxQw==", + "dev": true, "dependencies": { "npm-install-checks": "^6.0.0", "npm-normalize-package-bin": "^3.0.0", @@ -18587,6 +18630,7 @@ "version": "17.1.0", "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-17.1.0.tgz", "integrity": "sha512-5+bKQRH0J1xG1uZ1zMNvxW0VEyoNWgJpY9UDuluPFLKDfJ9u2JmmjmTJV1srBGQOROfdBMiVvnH2Zvpbm+xkVA==", + "dev": true, "dependencies": { "@npmcli/redact": "^2.0.0", "jsonparse": "^1.3.1", @@ -18605,6 +18649,7 @@ "version": "18.0.4", "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", "integrity": "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==", + "dev": true, "dependencies": { "@npmcli/fs": "^3.1.0", "fs-minipass": "^3.0.0", @@ -18626,12 +18671,14 @@ "node_modules/npm-registry-fetch/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/npm-registry-fetch/node_modules/make-fetch-happen": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==", + "dev": true, "dependencies": { "@npmcli/agent": "^2.0.0", "cacache": "^18.0.0", @@ -18654,6 +18701,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, "dependencies": { "minipass": "^7.0.3" }, @@ -21801,6 +21849,7 @@ "version": "18.0.6", "resolved": "https://registry.npmjs.org/pacote/-/pacote-18.0.6.tgz", "integrity": "sha512-+eK3G27SMwsB8kLIuj4h1FUhHtwiEUo21Tw8wNjmvdlpOEr613edv+8FUsTj/4F/VN5ywGE19X18N7CC2EJk6A==", + "dev": true, "dependencies": { "@npmcli/git": "^5.0.0", "@npmcli/installed-package-contents": "^2.0.1", @@ -21831,6 +21880,7 @@ "version": "18.0.4", "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", "integrity": "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==", + "dev": true, "dependencies": { "@npmcli/fs": "^3.1.0", "fs-minipass": "^3.0.0", @@ -21852,12 +21902,14 @@ "node_modules/pacote/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/pacote/node_modules/minipass-collect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, "dependencies": { "minipass": "^7.0.3" }, @@ -22757,6 +22809,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "dev": true, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -24300,6 +24353,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.3.1.tgz", "integrity": "sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==", + "dev": true, "dependencies": { "@sigstore/bundle": "^2.3.2", "@sigstore/core": "^1.0.0", @@ -25121,6 +25175,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -25532,7 +25587,8 @@ "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true }, "node_modules/thenify": { "version": "3.3.1", @@ -25851,6 +25907,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.1.tgz", "integrity": "sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==", + "dev": true, "dependencies": { "@tufjs/models": "2.0.1", "debug": "^4.3.4", @@ -25864,6 +25921,7 @@ "version": "18.0.4", "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", "integrity": "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==", + "dev": true, "dependencies": { "@npmcli/fs": "^3.1.0", "fs-minipass": "^3.0.0", @@ -25885,12 +25943,14 @@ "node_modules/tuf-js/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/tuf-js/node_modules/make-fetch-happen": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==", + "dev": true, "dependencies": { "@npmcli/agent": "^2.0.0", "cacache": "^18.0.0", @@ -25913,6 +25973,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, "dependencies": { "minipass": "^7.0.3" }, @@ -27333,6 +27394,7 @@ "version": "2.4.3", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz", "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==", + "dev": true, "dependencies": { "graceful-fs": "^4.1.11", "imurmurhash": "^0.1.4", @@ -27342,7 +27404,8 @@ "node_modules/write-file-atomic/node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true }, "node_modules/ws": { "version": "8.17.1", diff --git a/frontend/package.json b/frontend/package.json index 863197cf3..db5fd6a0c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,6 @@ "highlight.js": "^11.10.0", "http-status-codes": "^2.3.0", "monaco-editor": "^0.50.0", - "ngx-cookie": "^6.0.1", "ngx-skeleton-loader": "^9.0.0", "ngx-toastr": "^19.0.0", "npm": "^10.8.2", diff --git a/frontend/src/app/general/auth/auth-guard/auth-guard.service.ts b/frontend/src/app/general/auth/auth-guard/auth-guard.service.ts index a05a6b638..4b173132b 100644 --- a/frontend/src/app/general/auth/auth-guard/auth-guard.service.ts +++ b/frontend/src/app/general/auth/auth-guard/auth-guard.service.ts @@ -21,8 +21,7 @@ export const authGuard: CanActivateFn = ( return true; } else { // Needs window.location, since Router.url isn't updated yet - authService.cacheCurrentPath(window.location.pathname); - authService.webSSO(); + authService.login(window.location.pathname); return false; } }; diff --git a/frontend/src/app/general/auth/auth-redirect/auth-redirect.component.ts b/frontend/src/app/general/auth/auth-redirect/auth-redirect.component.ts index 532466b51..d5fb87511 100644 --- a/frontend/src/app/general/auth/auth-redirect/auth-redirect.component.ts +++ b/frontend/src/app/general/auth/auth-redirect/auth-redirect.component.ts @@ -5,6 +5,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { ToastService } from 'src/app/helpers/toast/toast.service'; import { AuthService } from 'src/app/services/auth/auth.service'; import { UserWrapperService } from 'src/app/services/user/user.service'; @@ -16,6 +17,7 @@ import { UserWrapperService } from 'src/app/services/user/user.service'; export class AuthRedirectComponent implements OnInit { constructor( private route: ActivatedRoute, + private toastService: ToastService, private authService: AuthService, private userService: UserWrapperService, private router: Router, @@ -33,17 +35,48 @@ export class AuthRedirectComponent implements OnInit { : '', ) .join('&'); + localStorage.setItem(this.authService.LOGGED_IN_KEY, 'false'); this.router.navigateByUrl(redirect_url); return; } - this.authService - .getAccessToken(params.code, params.state) - .subscribe((res) => { - this.authService.logIn(res.access_token, res.refresh_token); - this.userService.updateOwnUser(); - this.router.navigateByUrl(this.authService.getCurrentPath()); - }); + const redirectTo = sessionStorage.getItem(params.state); + const nonce = sessionStorage.getItem( + this.authService.SESSION_STORAGE_NONCE_KEY, + ); + const codeVerifier = sessionStorage.getItem( + this.authService.SESSION_STORAGE_CODE_VERIFIER_KEY, + ); + + if (nonce === null || codeVerifier === null) { + this.toastService.showError( + 'Missing nonce or code verifier value', + 'The nonce or code verifier value is missing in the session storage. If you initiated the login yourself, please retry, and if the error persists or you did not initiate the login, please contact your system administrator', + ); + } else if (redirectTo === null) { + this.toastService.showError( + 'State mismatch error', + 'The state returned by the authentication server does not match the local state. If you initiated the login yourself, please retry, and if the error persists or you did not initiate the login, please contact your system administrator.', + ); + this.router.navigateByUrl('/auth'); + } else { + sessionStorage.removeItem(params.state); + sessionStorage.removeItem(this.authService.SESSION_STORAGE_NONCE_KEY); + sessionStorage.removeItem( + this.authService.SESSION_STORAGE_CODE_VERIFIER_KEY, + ); + + this.authService + .getIdentityToken(params.code, nonce, codeVerifier) + .subscribe({ + next: () => { + localStorage.setItem(this.authService.LOGGED_IN_KEY, 'true'); + this.userService.updateOwnUser(); + this.router.navigateByUrl(redirectTo); + }, + error: () => this.router.navigateByUrl('/auth'), + }); + } }); } } diff --git a/frontend/src/app/general/auth/auth/auth.component.html b/frontend/src/app/general/auth/auth/auth.component.html index cf52f0d25..2d0da71fa 100644 --- a/frontend/src/app/general/auth/auth/auth.component.html +++ b/frontend/src/app/general/auth/auth/auth.component.html @@ -24,7 +24,11 @@

Capella Collaboration Manager

- diff --git a/frontend/src/app/general/auth/auth/auth.component.ts b/frontend/src/app/general/auth/auth/auth.component.ts index b5dd7c87b..7826f04ca 100644 --- a/frontend/src/app/general/auth/auth/auth.component.ts +++ b/frontend/src/app/general/auth/auth/auth.component.ts @@ -22,7 +22,7 @@ export class AuthComponent implements OnInit { @Input() set autoLogin(value: boolean) { if (value) { - this.authService.webSSO(); + this.authService.login('/'); } } @@ -30,7 +30,7 @@ export class AuthComponent implements OnInit { constructor( public metadataService: MetadataService, - private authService: AuthService, + public authService: AuthService, private pageLayoutService: PageLayoutService, private route: ActivatedRoute, ) { @@ -42,8 +42,4 @@ export class AuthComponent implements OnInit { this.params = res; }); } - - webSSO() { - this.authService.webSSO(); - } } diff --git a/frontend/src/app/general/auth/http-interceptor/auth.interceptor.ts b/frontend/src/app/general/auth/http-interceptor/auth.interceptor.ts index 19e0e82f1..93bb384a6 100644 --- a/frontend/src/app/general/auth/http-interceptor/auth.interceptor.ts +++ b/frontend/src/app/general/auth/http-interceptor/auth.interceptor.ts @@ -14,10 +14,7 @@ import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Observable } from 'rxjs'; import { catchError, switchMap } from 'rxjs/operators'; -import { - AuthService, - RefreshTokenResponse, -} from 'src/app/services/auth/auth.service'; +import { AuthService } from 'src/app/services/auth/auth.service'; @Injectable() export class AuthInterceptor implements HttpInterceptor { @@ -30,8 +27,8 @@ export class AuthInterceptor implements HttpInterceptor { request: HttpRequest, next: HttpHandler, ): Observable> { - const req = this.injectAccessToken(request); - return next.handle(req).pipe( + request = request.clone({ withCredentials: true }); + return next.handle(request).pipe( catchError((err: HttpErrorResponse) => { return this.handleTokenExpired(err, request, next); }), @@ -44,11 +41,11 @@ export class AuthInterceptor implements HttpInterceptor { next: HttpHandler, ) { if (err.status === 401) { + localStorage.setItem(this.authService.LOGGED_IN_KEY, 'false'); if (err.error.detail.err_code == 'TOKEN_SIGNATURE_EXPIRED') { - return this.refreshToken().pipe( + return this.authService.performTokenRefresh().pipe( switchMap(() => { - const req = this.injectAccessToken(request); - return next.handle(req); + return next.handle(request); }), catchError(() => { this.router.navigateByUrl('/logout?reason=session-expired'); @@ -59,18 +56,6 @@ export class AuthInterceptor implements HttpInterceptor { this.router.navigateByUrl('/logout?reason=unauthorized'); } } - throw err; } - - injectAccessToken(request: HttpRequest): HttpRequest { - const access_token = this.authService.accessToken; - return request.clone({ - headers: request.headers.set('Authorization', `Bearer ${access_token}`), - }); - } - - refreshToken(): Observable { - return this.authService.performTokenRefresh(); - } } diff --git a/frontend/src/app/general/header/header.component.html b/frontend/src/app/general/header/header.component.html index 742ad9bec..c22f48aa6 100644 --- a/frontend/src/app/general/header/header.component.html +++ b/frontend/src/app/general/header/header.component.html @@ -32,9 +32,9 @@ class="" > {{ item.name }} - {{ - item.icon - }} + @if (item.icon) { + {{ item.icon }} + } } @else { Profile account_circle - - Settings settings - - - Events event_note - + + @if (userService.user?.role === "administrator") { + + Settings settings + + + Events event_note + + } Tokens key - - Log out logout - + + @if (authService.isLoggedIn()) { + + Log out logout + + }
- - + @if (["user"].includes(user.role)) { + + }