From 0464047f8a6d17590f06fc7c52c4b1d4e007b2ce Mon Sep 17 00:00:00 2001 From: phil Date: Wed, 12 Feb 2025 03:21:06 +0100 Subject: [PATCH 01/37] Container: add demo plugin --- Containerfile | 3 +++ src/oidc_test/templates/home.html | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Containerfile b/Containerfile index aef57f8..75fef2b 100644 --- a/Containerfile +++ b/Containerfile @@ -9,6 +9,9 @@ WORKDIR /app RUN uv pip install --system . +# Add demo plugin +RUN PIP_EXTRA_INDEX_URL=https://pypi.org/simple/ uv pip install --system --index-url https://code.philo.ydns.eu/api/packages/philorg/pypi/simple/ oidc-fastapi-test-petition + # Possible to run with: #CMD ["oidc-test", "--port", "80"] #CMD ["fastapi", "run", "src/oidc_test/main.py", "--port", "8873", "--root-path", "/oidc-test"] diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html index 790da81..23ba7ff 100644 --- a/src/oidc_test/templates/home.html +++ b/src/oidc_test/templates/home.html @@ -56,7 +56,7 @@ Account management {% endif %} - + {% endif %} From 381ce1ebc175d899cca49de14b8b7b6a6b263866 Mon Sep 17 00:00:00 2001 From: phil Date: Thu, 13 Feb 2025 12:23:18 +0100 Subject: [PATCH 02/37] Use pydantic on ResourceServer --- src/oidc_test/main.py | 3 +-- src/oidc_test/registry.py | 10 +++++----- src/oidc_test/resource_server.py | 13 ++++++++++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py index 28eab8a..3858a08 100644 --- a/src/oidc_test/main.py +++ b/src/oidc_test/main.py @@ -58,7 +58,7 @@ async def lifespan(app: FastAPI): try: await provider.get_info() except NoPublicKey: - logger.warn(f"Disable {provider.id}: public key not found") + logger.warning(f"Disable {provider.id}: public key not found") del providers[provider.id] yield @@ -300,7 +300,6 @@ async def refresh( await update_token(provider.id, new_token) return RedirectResponse(url=request.url_for("home")) - # Snippet for running standalone # Mostly useful for the --version option, # as running with uvicorn is easy and provides better flexibility, eg. diff --git a/src/oidc_test/registry.py b/src/oidc_test/registry.py index 6db0a47..a184ec0 100644 --- a/src/oidc_test/registry.py +++ b/src/oidc_test/registry.py @@ -17,20 +17,20 @@ class ProcessError(Exception): pass -class ResourceProvider: - name: str +class ResourceProvider(BaseModel): scope_required: str | None = None default_resource_id: str | None = None def __init__(self, name: str): - self.name = name + super().__init__() + self.__name__ = name async def process(self, user: User, resource_id: str | None = None) -> ProcessResult: - logger.warning(f"{self.name} should define a process method") + logger.warning(f"{self.__name__} should define a process method") return ProcessResult() -class ResourceRegistry: +class ResourceRegistry(BaseModel): resource_providers: dict[str, ResourceProvider] = {} def make_registry(self): diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py index 3b89240..1af0f6b 100644 --- a/src/oidc_test/resource_server.py +++ b/src/oidc_test/resource_server.py @@ -1,5 +1,4 @@ -from datetime import datetime -from typing import Annotated +from typing import Annotated, Any import logging from authlib.oauth2.auth import OAuth2Token @@ -21,7 +20,7 @@ from oidc_test.auth.utils import ( from oidc_test.auth_providers import providers from oidc_test.settings import settings from oidc_test.models import User -from oidc_test.registry import ProcessError, ProcessResult, registry +from oidc_test.registry import ProcessError, ProcessResult, ResourceProvider, registry logger = logging.getLogger("oidc-test") @@ -48,6 +47,14 @@ resource_server.add_middleware( # Routes for RBAC based tests +@resource_server.get("/") +async def resources() -> dict[str, dict[str, Any]]: + return { + "internal": {}, + "plugins": registry.resource_providers + } + + @resource_server.get("/public") async def public() -> dict: return {"msg": "Not protected"} From 9d3146dc1c895d7a4eebace39af6222ebbbea091 Mon Sep 17 00:00:00 2001 From: phil Date: Thu, 13 Feb 2025 18:15:26 +0100 Subject: [PATCH 03/37] Add role protection to resource servers, remove hardcoded resources --- src/oidc_test/auth/utils.py | 51 ++++++++----- src/oidc_test/main.py | 3 +- src/oidc_test/registry.py | 7 +- src/oidc_test/resource_server.py | 118 +++++++++++++++++------------- src/oidc_test/settings.py | 1 + src/oidc_test/static/styles.css | 14 +++- src/oidc_test/templates/home.html | 43 +++-------- 7 files changed, 127 insertions(+), 110 deletions(-) diff --git a/src/oidc_test/auth/utils.py b/src/oidc_test/auth/utils.py index 9479c48..acd68b5 100644 --- a/src/oidc_test/auth/utils.py +++ b/src/oidc_test/auth/utils.py @@ -8,7 +8,7 @@ from authlib.integrations.starlette_client import OAuth, OAuthError, StarletteOA from jwt import ExpiredSignatureError, InvalidKeyError, DecodeError, PyJWTError # from authlib.oauth1.auth import OAuthToken -from authlib.oauth2.auth import OAuth2Token +from authlib.oauth2.rfc6749 import OAuth2Token from oidc_test.auth.provider import Provider from oidc_test.models import User @@ -60,25 +60,28 @@ def init_providers(): sub="", auth_provider_id=provider_settings.id ) provider = Provider(**provider_settings_dict) - authlib_oauth.register( - name=provider.id, - server_metadata_url=provider.openid_configuration, - client_kwargs={ - "scope": " ".join( - ["openid", "email", "offline_access", "profile"] - + provider.resource_provider_scopes - ), - }, - client_id=provider.client_id, - client_secret=provider.client_secret, - api_base_url=provider.url, - # For PKCE (not implemented yet): - # code_challenge_method="S256", - fetch_token=fetch_token, - update_token=update_token, - # client_id="some-client-id", # if enabled, authlib will also check that the access token belongs to this client id (audience) - ) - provider.authlib_client = getattr(authlib_oauth, provider.id) + if provider.disabled: + logger.info(f"{provider_settings.name} is disabled, skipping") + else: + authlib_oauth.register( + name=provider.id, + server_metadata_url=provider.openid_configuration, + client_kwargs={ + "scope": " ".join( + ["openid", "email", "offline_access", "profile"] + + provider.resource_provider_scopes + ), + }, + client_id=provider.client_id, + client_secret=provider.client_secret, + api_base_url=provider.url, + # For PKCE (not implemented yet): + # code_challenge_method="S256", + fetch_token=fetch_token, + update_token=update_token, + # client_id="some-client-id", # if enabled, authlib will also check that the access token belongs to this client id (audience) + ) + provider.authlib_client = getattr(authlib_oauth, provider.id) providers[provider.id] = provider @@ -270,6 +273,14 @@ async def get_user_from_token( ) return user +async def get_user_from_token_or_none( + token: Annotated[str, Depends(oauth2_scheme)], + request: Request, +) -> User | None: + try: + return await get_user_from_token(token, request) + except HTTPException: + return None class UserWithRole: roles: set[str] diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py index 3858a08..9e8b135 100644 --- a/src/oidc_test/main.py +++ b/src/oidc_test/main.py @@ -55,6 +55,8 @@ async def lifespan(app: FastAPI): init_providers() registry.make_registry() for provider in list(providers.values()): + if provider.disabled: + continue try: await provider.get_info() except NoPublicKey: @@ -106,7 +108,6 @@ async def home( else: context["access_token"] = token["access_token"] # XXX: resources defined externally? I am confused... - context["resources"] = provider.resources try: access_token_parsed = provider.decode(token["access_token"], verify_signature=False) except PyJWTError as err: diff --git a/src/oidc_test/registry.py b/src/oidc_test/registry.py index a184ec0..e9c9809 100644 --- a/src/oidc_test/registry.py +++ b/src/oidc_test/registry.py @@ -18,15 +18,18 @@ class ProcessError(Exception): class ResourceProvider(BaseModel): + name: str scope_required: str | None = None + role_required: str | None = None + is_public: bool = False default_resource_id: str | None = None def __init__(self, name: str): super().__init__() - self.__name__ = name + self.__id__ = name async def process(self, user: User, resource_id: str | None = None) -> ProcessResult: - logger.warning(f"{self.__name__} should define a process method") + logger.warning(f"{self.__id__} should define a process method") return ProcessResult() diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py index 1af0f6b..03d109e 100644 --- a/src/oidc_test/resource_server.py +++ b/src/oidc_test/resource_server.py @@ -1,7 +1,7 @@ from typing import Annotated, Any import logging -from authlib.oauth2.auth import OAuth2Token +from authlib.oauth2.rfc6749 import OAuth2Token from httpx import AsyncClient from jwt.exceptions import ExpiredSignatureError, InvalidTokenError from fastapi import FastAPI, HTTPException, Depends, Request, status @@ -16,6 +16,7 @@ from oidc_test.auth.utils import ( get_token_or_none, get_user_from_token, UserWithRole, + get_user_from_token_or_none, ) from oidc_test.auth_providers import providers from oidc_test.settings import settings @@ -55,54 +56,12 @@ async def resources() -> dict[str, dict[str, Any]]: } -@resource_server.get("/public") -async def public() -> dict: - return {"msg": "Not protected"} - - -@resource_server.get("/protected") -async def get_protected(user: Annotated[User, Depends(get_user_from_token)]): - assert user is not None # Just to keep QA checks happy - return {"msg": "Only authenticated users can see this"} - - -@resource_server.get("/protected-by-foorole") -async def get_protected_by_foorole( - user: Annotated[User, Depends(UserWithRole("foorole"))], -): - assert user is not None - return {"msg": "Only users with foorole can see this"} - - -@resource_server.get("/protected-by-barrole") -async def get_protected_by_barrole( - user: Annotated[User, Depends(UserWithRole("barrole"))], -): - assert user is not None - return {"msg": "Protected by barrole"} - - -@resource_server.get("/protected-by-foorole-and-barrole") -async def get_protected_by_foorole_and_barrole( - user: Annotated[User, Depends(UserWithRole("foorole")), Depends(UserWithRole("barrole"))], -): - assert user is not None # Just to keep QA checks happy - return {"msg": "Only users with foorole and barrole can see this"} - - -@resource_server.get("/protected-by-foorole-or-barrole") -async def get_protected_by_foorole_or_barrole( - user: Annotated[User, Depends(UserWithRole(["foorole", "barrole"]))], -): - assert user is not None # Just to keep QA checks happy - return {"msg": "Only users with foorole or barrole can see this"} - @resource_server.get("/{resource_name}") @resource_server.get("/{resource_name}/{resource_id}") async def get_resource( resource_name: str, - user: Annotated[User, Depends(get_user_from_token)], + user: Annotated[User, Depends(get_user_from_token_or_none)], token: Annotated[OAuth2Token | None, Depends(get_token_or_none)], request: Request, resource_id: str | None = None, @@ -111,19 +70,29 @@ async def get_resource( provider = providers[user.auth_provider_id] # Third party resource (provided through the auth provider) # The token is just passed on + breakpoint() if resource_name in [r.resource_name for r in provider.resources]: return await get_auth_provider_resource( provider=provider, resource_name=resource_name, - access_token=token["access_token"] if token else None, + token=token, user=user, ) # Internal resource (provided here) if resource_name in registry.resource_providers: resource_provider = registry.resource_providers[resource_name] - if resource_provider.scope_required is not None and user.has_scope( - resource_provider.scope_required - ): + reasons: dict[str, str] = {} + if not resource_provider.is_public: + if resource_provider.scope_required is not None and not user.has_scope( + resource_provider.scope_required + ): + reasons["scope"] = f"No scope {resource_provider.scope_required} in the access token " \ + "but it is required for accessing this resource" + if resource_provider.role_required is not None \ + and resource_provider.role_required not in user.roles_as_set: + reasons["role"] = f"You don't have the role {resource_provider.role_required} " \ + "but it is required for accessing this resource" + if len(reasons) == 0: try: return await resource_provider.process(user=user, resource_id=resource_id) except ProcessError as err: @@ -132,9 +101,7 @@ async def get_resource( ) else: raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - f"No scope {resource_provider.scope_required} in the access token " - + "but it is required for accessing this resource", + status.HTTP_401_UNAUTHORIZED, ", ".join(reasons.values()) ) else: raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Unknown resource {resource_name}") @@ -142,8 +109,13 @@ async def get_resource( async def get_auth_provider_resource( - provider: Provider, resource_name: str, access_token: str | None, user: User + provider: Provider, resource_name: str, token: OAuth2Token | None, user: User ) -> ProcessResult: + if token is None: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, f"No auth token" + ) + access_token = token["access_token"] resource = [r for r in provider.resources if r.resource_name == resource_name][0] async with AsyncClient() as client: resp = await client.get( @@ -165,6 +137,48 @@ async def get_auth_provider_resource( return ProcessResult(result=resp.json()) +#@resource_server.get("/public") +#async def public() -> dict: +# return {"msg": "Not protected"} +# +# +#@resource_server.get("/protected") +#async def get_protected(user: Annotated[User, Depends(get_user_from_token)]): +# assert user is not None # Just to keep QA checks happy +# return {"msg": "Only authenticated users can see this"} +# +# +#@resource_server.get("/protected-by-foorole") +#async def get_protected_by_foorole( +# user: Annotated[User, Depends(UserWithRole("foorole"))], +#): +# assert user is not None +# return {"msg": "Only users with foorole can see this"} +# +# +#@resource_server.get("/protected-by-barrole") +#async def get_protected_by_barrole( +# user: Annotated[User, Depends(UserWithRole("barrole"))], +#): +# assert user is not None +# return {"msg": "Protected by barrole"} +# +# +#@resource_server.get("/protected-by-foorole-and-barrole") +#async def get_protected_by_foorole_and_barrole( +# user: Annotated[User, Depends(UserWithRole("foorole")), Depends(UserWithRole("barrole"))], +#): +# assert user is not None # Just to keep QA checks happy +# return {"msg": "Only users with foorole and barrole can see this"} +# +# +#@resource_server.get("/protected-by-foorole-or-barrole") +#async def get_protected_by_foorole_or_barrole( +# user: Annotated[User, Depends(UserWithRole(["foorole", "barrole"]))], +#): +# assert user is not None # Just to keep QA checks happy +# return {"msg": "Only users with foorole or barrole can see this"} + # async def get_resource_(resource_id: str, user: User, **kwargs) -> dict: # """ # Resource processing: build an informative rely as a simple showcase diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py index 2acbc3f..3e7001c 100644 --- a/src/oidc_test/settings.py +++ b/src/oidc_test/settings.py @@ -44,6 +44,7 @@ class AuthProviderSettings(BaseModel): resource_provider_scopes: list[str] = [] session_key: str = "sid" skip_verify_signature: bool = True + disabled: bool = False @computed_field @property diff --git a/src/oidc_test/static/styles.css b/src/oidc_test/static/styles.css index e163a68..2baa748 100644 --- a/src/oidc_test/static/styles.css +++ b/src/oidc_test/static/styles.css @@ -142,19 +142,27 @@ hr { .providers .provider { min-height: 2em; } -.providers .provider a.link { +.providers .provider .link { text-decoration: none; max-height: 2em; } -.providers .provider .link div { +.providers .provider .link { background-color: #f7c7867d; border-radius: 8px; padding: 6px; text-align: center; color: black; - font-weight: bold; + font-weight: 400; cursor: pointer; + border: 0; + width: 100%; } + +.providers .provider .link.disabled { + color: gray; + cursor: not-allowed; +} + .providers .provider .hint { font-size: 80%; max-width: 13em; diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html index 23ba7ff..ecefb0f 100644 --- a/src/oidc_test/templates/home.html +++ b/src/oidc_test/templates/home.html @@ -11,7 +11,11 @@ {% for provider in providers.values() %} -
{{ provider.name }}
+ {{ provider.hint }} @@ -62,42 +66,17 @@ {% endif %}
-

- Resources validated by role: -

- - {% if resource_providers %}

- Resource providers (validated by scope): + Resource providers:

{% endif %} From 5bd4b8280427d4a070a724cee141ee52ed3664a4 Mon Sep 17 00:00:00 2001 From: phil Date: Thu, 13 Feb 2025 18:26:23 +0100 Subject: [PATCH 04/37] Update demo resource provider package name --- Containerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Containerfile b/Containerfile index 75fef2b..2e3fd28 100644 --- a/Containerfile +++ b/Containerfile @@ -10,7 +10,7 @@ WORKDIR /app RUN uv pip install --system . # Add demo plugin -RUN PIP_EXTRA_INDEX_URL=https://pypi.org/simple/ uv pip install --system --index-url https://code.philo.ydns.eu/api/packages/philorg/pypi/simple/ oidc-fastapi-test-petition +RUN PIP_EXTRA_INDEX_URL=https://pypi.org/simple/ uv pip install --system --index-url https://code.philo.ydns.eu/api/packages/philorg/pypi/simple/ oidc-fastapi-test-resource-provider-demo # Possible to run with: #CMD ["oidc-test", "--port", "80"] From 40ddb616363e303968f1b9d5e7291f0868ac7656 Mon Sep 17 00:00:00 2001 From: phil Date: Thu, 13 Feb 2025 18:26:48 +0100 Subject: [PATCH 05/37] Cleanup --- src/oidc_test/resource_server.py | 1 - src/oidc_test/templates/home.html | 15 ++++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py index 03d109e..1877875 100644 --- a/src/oidc_test/resource_server.py +++ b/src/oidc_test/resource_server.py @@ -70,7 +70,6 @@ async def get_resource( provider = providers[user.auth_provider_id] # Third party resource (provided through the auth provider) # The token is just passed on - breakpoint() if resource_name in [r.resource_name for r in provider.resources]: return await get_auth_provider_resource( provider=provider, diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html index ecefb0f..6c4e6a6 100644 --- a/src/oidc_test/templates/home.html +++ b/src/oidc_test/templates/home.html @@ -68,14 +68,23 @@
{% if resource_providers %}

- Resource providers: + {{ auth_provider.name }} provides these resources:

From c89ca4098b2165014890af89caebde7310b88db0 Mon Sep 17 00:00:00 2001 From: phil Date: Fri, 14 Feb 2025 13:21:55 +0100 Subject: [PATCH 06/37] Fix public resource access; free resource response validation; formatting --- src/oidc_test/auth/utils.py | 15 ++-- src/oidc_test/main.py | 10 +-- src/oidc_test/registry.py | 9 ++- src/oidc_test/resource_server.py | 127 ++++++++++++++++--------------- 4 files changed, 84 insertions(+), 77 deletions(-) diff --git a/src/oidc_test/auth/utils.py b/src/oidc_test/auth/utils.py index acd68b5..7dd0e3d 100644 --- a/src/oidc_test/auth/utils.py +++ b/src/oidc_test/auth/utils.py @@ -87,6 +87,7 @@ def init_providers(): authlib_oauth = OAuth(cache=None, fetch_token=fetch_token, update_token=update_token) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") +oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) def get_auth_provider_client_or_none(request: Request) -> StarletteOAuth2App | None: @@ -125,7 +126,7 @@ async def get_current_user(request: Request) -> User: """ if (user_sub := request.session.get("user_sub")) is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED) - token = await get_token(request) + token = await get_token_from_session(request) user = await db.get_user(user_sub) ## Check if the token is expired if token.is_expired(): @@ -146,16 +147,16 @@ async def get_current_user(request: Request) -> User: return user -async def get_token_or_none(request: Request) -> OAuth2Token | None: +async def get_token_from_session_or_none(request: Request) -> OAuth2Token | None: """Return the auth token from the session or None. Can be used in Depends()""" try: - return await get_token(request) + return await get_token_from_session(request) except HTTPException: return None -async def get_token(request: Request) -> OAuth2Token: +async def get_token_from_session(request: Request) -> OAuth2Token: """Return the token from the session. Can be used in Depends()""" try: @@ -273,15 +274,19 @@ async def get_user_from_token( ) return user + async def get_user_from_token_or_none( - token: Annotated[str, Depends(oauth2_scheme)], + token: Annotated[str | None, Depends(oauth2_scheme_optional)], request: Request, ) -> User | None: + if token is None: + return None try: return await get_user_from_token(token, request) except HTTPException: return None + class UserWithRole: roles: set[str] diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py index 9e8b135..9f5e746 100644 --- a/src/oidc_test/main.py +++ b/src/oidc_test/main.py @@ -33,8 +33,8 @@ from oidc_test.auth.utils import ( get_auth_provider_or_none, get_current_user_or_none, authlib_oauth, - get_token_or_none, - get_token, + get_token_from_session_or_none, + get_token_from_session, update_token, ) from oidc_test.auth.utils import init_providers @@ -88,9 +88,9 @@ app.mount("/resource", resource_server, name="resource_server") @app.get("/") async def home( request: Request, - user: Annotated[User, Depends(get_current_user_or_none)], + user: Annotated[User | None, Depends(get_current_user_or_none)], provider: Annotated[Provider | None, Depends(get_auth_provider_or_none)], - token: Annotated[OAuth2Token | None, Depends(get_token_or_none)], + token: Annotated[OAuth2Token | None, Depends(get_token_from_session_or_none)], ) -> HTMLResponse: context = { "show_token": settings.show_token, @@ -291,7 +291,7 @@ async def non_compliant_logout( async def refresh( request: Request, provider: Annotated[Provider, Depends(get_auth_provider)], - token: Annotated[OAuth2Token, Depends(get_token)], + token: Annotated[OAuth2Token, Depends(get_token_from_session)], ) -> RedirectResponse: """Manually refresh token""" new_token = await provider.authlib_client.fetch_access_token( diff --git a/src/oidc_test/registry.py b/src/oidc_test/registry.py index e9c9809..794a843 100644 --- a/src/oidc_test/registry.py +++ b/src/oidc_test/registry.py @@ -1,8 +1,7 @@ from importlib.metadata import entry_points import logging -from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from oidc_test.models import User @@ -10,7 +9,9 @@ logger = logging.getLogger("registry") class ProcessResult(BaseModel): - result: dict[str, Any] = {} + model_config = ConfigDict( + extra="allow", + ) class ProcessError(Exception): @@ -28,7 +29,7 @@ class ResourceProvider(BaseModel): super().__init__() self.__id__ = name - async def process(self, user: User, resource_id: str | None = None) -> ProcessResult: + async def process(self, user: User | None, resource_id: str | None = None) -> ProcessResult: logger.warning(f"{self.__id__} should define a process method") return ProcessResult() diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py index 1877875..ee4ff10 100644 --- a/src/oidc_test/resource_server.py +++ b/src/oidc_test/resource_server.py @@ -13,15 +13,13 @@ from fastapi.middleware.cors import CORSMiddleware from oidc_test.auth.provider import Provider from oidc_test.auth.utils import ( - get_token_or_none, - get_user_from_token, - UserWithRole, get_user_from_token_or_none, + oauth2_scheme_optional, ) from oidc_test.auth_providers import providers from oidc_test.settings import settings from oidc_test.models import User -from oidc_test.registry import ProcessError, ProcessResult, ResourceProvider, registry +from oidc_test.registry import ProcessError, ProcessResult, registry logger = logging.getLogger("oidc-test") @@ -50,60 +48,67 @@ resource_server.add_middleware( @resource_server.get("/") async def resources() -> dict[str, dict[str, Any]]: - return { - "internal": {}, - "plugins": registry.resource_providers - } - + return {"internal": {}, "plugins": registry.resource_providers} @resource_server.get("/{resource_name}") @resource_server.get("/{resource_name}/{resource_id}") async def get_resource( resource_name: str, - user: Annotated[User, Depends(get_user_from_token_or_none)], - token: Annotated[OAuth2Token | None, Depends(get_token_or_none)], - request: Request, + user: Annotated[User | None, Depends(get_user_from_token_or_none)], + token: Annotated[OAuth2Token | None, Depends(oauth2_scheme_optional)], resource_id: str | None = None, -) -> ProcessResult: - """Generic path for testing a resource provided by a provider""" - provider = providers[user.auth_provider_id] - # Third party resource (provided through the auth provider) - # The token is just passed on - if resource_name in [r.resource_name for r in provider.resources]: - return await get_auth_provider_resource( - provider=provider, - resource_name=resource_name, - token=token, - user=user, - ) +): + """Generic path for testing a resource provided by a provider. + There's no field validation (response type of ProcessResult) on purpose, + leaving the responsibility of the response validation to resource providers""" + # Get the resource if it's defined in user auth provider's resources (external) + if user is not None: + provider = providers[user.auth_provider_id] + # Third party resource (provided through the auth provider) + # The token is just passed on + if resource_name in [r.resource_name for r in provider.resources]: + return await get_auth_provider_resource( + provider=provider, + resource_name=resource_name, + token=token, + user=user, + ) # Internal resource (provided here) if resource_name in registry.resource_providers: resource_provider = registry.resource_providers[resource_name] - reasons: dict[str, str] = {} + reason: dict[str, str] = {} if not resource_provider.is_public: - if resource_provider.scope_required is not None and not user.has_scope( - resource_provider.scope_required - ): - reasons["scope"] = f"No scope {resource_provider.scope_required} in the access token " \ - "but it is required for accessing this resource" - if resource_provider.role_required is not None \ - and resource_provider.role_required not in user.roles_as_set: - reasons["role"] = f"You don't have the role {resource_provider.role_required} " \ - "but it is required for accessing this resource" - if len(reasons) == 0: + if user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Resource is not public") + else: + if resource_provider.scope_required is not None and not user.has_scope( + resource_provider.scope_required + ): + reason["scope"] = ( + f"No scope {resource_provider.scope_required} in the access token " + "but it is required for accessing this resource" + ) + if ( + resource_provider.role_required is not None + and resource_provider.role_required not in user.roles_as_set + ): + reason["role"] = ( + f"You don't have the role {resource_provider.role_required} " + "but it is required for accessing this resource" + ) + if len(reason) == 0: try: - return await resource_provider.process(user=user, resource_id=resource_id) + resp = await resource_provider.process(user=user, resource_id=resource_id) + return resp except ProcessError as err: raise HTTPException( status.HTTP_401_UNAUTHORIZED, f"Cannot process resource: {err}" ) else: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, ", ".join(reasons.values()) - ) + raise HTTPException(status.HTTP_401_UNAUTHORIZED, ", ".join(reason.values())) else: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Unknown resource {resource_name}") + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Unknown resource") # return await get_resource_(resource_name, user, **request.query_params) @@ -111,9 +116,7 @@ async def get_auth_provider_resource( provider: Provider, resource_name: str, token: OAuth2Token | None, user: User ) -> ProcessResult: if token is None: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, f"No auth token" - ) + raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"No auth token") access_token = token["access_token"] resource = [r for r in provider.resources if r.resource_name == resource_name][0] async with AsyncClient() as client: @@ -129,52 +132,50 @@ async def get_auth_provider_resource( # Only a demo, real application would really process the response resp_length = len(resp.text) if resp_length > 1024: - return ProcessResult( - result={"msg": f"The resource is too long ({resp_length} bytes) to show here"} - ) + return ProcessResult(msg=f"The resource is too long ({resp_length} bytes) to show here") else: - return ProcessResult(result=resp.json()) + return ProcessResult(**resp.json()) -#@resource_server.get("/public") -#async def public() -> dict: +# @resource_server.get("/public") +# async def public() -> dict: # return {"msg": "Not protected"} # # -#@resource_server.get("/protected") -#async def get_protected(user: Annotated[User, Depends(get_user_from_token)]): +# @resource_server.get("/protected") +# async def get_protected(user: Annotated[User, Depends(get_user_from_token)]): # assert user is not None # Just to keep QA checks happy # return {"msg": "Only authenticated users can see this"} # # -#@resource_server.get("/protected-by-foorole") -#async def get_protected_by_foorole( +# @resource_server.get("/protected-by-foorole") +# async def get_protected_by_foorole( # user: Annotated[User, Depends(UserWithRole("foorole"))], -#): +# ): # assert user is not None # return {"msg": "Only users with foorole can see this"} # # -#@resource_server.get("/protected-by-barrole") -#async def get_protected_by_barrole( +# @resource_server.get("/protected-by-barrole") +# async def get_protected_by_barrole( # user: Annotated[User, Depends(UserWithRole("barrole"))], -#): +# ): # assert user is not None # return {"msg": "Protected by barrole"} # # -#@resource_server.get("/protected-by-foorole-and-barrole") -#async def get_protected_by_foorole_and_barrole( +# @resource_server.get("/protected-by-foorole-and-barrole") +# async def get_protected_by_foorole_and_barrole( # user: Annotated[User, Depends(UserWithRole("foorole")), Depends(UserWithRole("barrole"))], -#): +# ): # assert user is not None # Just to keep QA checks happy # return {"msg": "Only users with foorole and barrole can see this"} # # -#@resource_server.get("/protected-by-foorole-or-barrole") -#async def get_protected_by_foorole_or_barrole( +# @resource_server.get("/protected-by-foorole-or-barrole") +# async def get_protected_by_foorole_or_barrole( # user: Annotated[User, Depends(UserWithRole(["foorole", "barrole"]))], -#): +# ): # assert user is not None # Just to keep QA checks happy # return {"msg": "Only users with foorole or barrole can see this"} From 4008036bca36ae933b14fba5ed239ddd0e727d57 Mon Sep 17 00:00:00 2001 From: phil Date: Fri, 14 Feb 2025 13:36:22 +0100 Subject: [PATCH 07/37] CI: don't fail because of publish step (already exists) --- .forgejo/workflows/build.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index e02bf47..352a0a9 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -90,3 +90,4 @@ jobs: env: LOCAL_PYPI_TOKEN: ${{ secrets.LOCAL_PYPI_TOKEN }} run: uv publish --publish-url https://code.philo.ydns.eu/api/packages/philorg/pypi --token $LOCAL_PYPI_TOKEN + continue-on-error: true From 1c57944a902aa25bf7bd4527b4d83bfb6d1b81e2 Mon Sep 17 00:00:00 2001 From: phil Date: Mon, 17 Feb 2025 17:26:30 +0100 Subject: [PATCH 08/37] Fix typo --- src/oidc_test/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py index 9f5e746..79293a3 100644 --- a/src/oidc_test/main.py +++ b/src/oidc_test/main.py @@ -264,7 +264,7 @@ async def logout( { "post_logout_redirect_uri": post_logout_uri, "id_token_hint": token["id_token"], - "cliend_id": "oidc_local_test", + "client_id": "oidc_local_test", } ) ) @@ -301,6 +301,7 @@ async def refresh( await update_token(provider.id, new_token) return RedirectResponse(url=request.url_for("home")) + # Snippet for running standalone # Mostly useful for the --version option, # as running with uvicorn is easy and provides better flexibility, eg. From 435c11b6caf7ca59529a62eec664a2bbcf2d554a Mon Sep 17 00:00:00 2001 From: phil Date: Wed, 19 Feb 2025 04:07:57 +0100 Subject: [PATCH 09/37] Working use as third party resource provider --- src/oidc_test/auth/provider.py | 18 ++++++++- src/oidc_test/main.py | 26 ++++++------- src/oidc_test/registry.py | 10 ++--- src/oidc_test/resource_server.py | 62 +++++++++++++++++++++++-------- src/oidc_test/settings.py | 28 +++++++++----- src/oidc_test/static/utils.js | 10 +++-- src/oidc_test/templates/home.html | 43 +++++++++++++++------ 7 files changed, 138 insertions(+), 59 deletions(-) diff --git a/src/oidc_test/auth/provider.py b/src/oidc_test/auth/provider.py index 17dcaa0..c614805 100644 --- a/src/oidc_test/auth/provider.py +++ b/src/oidc_test/auth/provider.py @@ -7,7 +7,7 @@ from pydantic import ConfigDict from authlib.integrations.starlette_client.apps import StarletteOAuth2App from httpx import AsyncClient -from oidc_test.settings import AuthProviderSettings, settings +from oidc_test.settings import AuthProviderSettings, ResourceProvider, Resource, settings from oidc_test.models import User logger = logging.getLogger("oidc-test") @@ -24,6 +24,7 @@ class Provider(AuthProviderSettings): authlib_client: StarletteOAuth2App = StarletteOAuth2App(None) info: dict[str, Any] = {} unknown_auth_user: User + logout_with_id_token_hint: bool = True def decode(self, token: str, verify_signature: bool | None = None) -> dict[str, Any]: """Decode the token with signature check""" @@ -89,3 +90,18 @@ class Provider(AuthProviderSettings): def get_session_key(self, userinfo): return userinfo[self.session_key] + + def get_resource(self, resource_name: str) -> Resource: + return [ + resource for resource in self.resources if resource.resource_name == resource_name + ][0] + + def get_resource_url(self, resource_name: str) -> str: + return self.url + self.get_resource(resource_name).url + + def get_resource_provider(self, resource_provider_id: str) -> ResourceProvider: + return [ + provider + for provider in self.resource_providers + if provider.id == resource_provider_id + ][0] diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py index 79293a3..f930e48 100644 --- a/src/oidc_test/main.py +++ b/src/oidc_test/main.py @@ -107,7 +107,6 @@ async def home( context["resources"] = None else: context["access_token"] = token["access_token"] - # XXX: resources defined externally? I am confused... try: access_token_parsed = provider.decode(token["access_token"], verify_signature=False) except PyJWTError as err: @@ -118,7 +117,8 @@ async def home( context["access_token_scope"] = None context["id_token_parsed"] = provider.decode(token["id_token"], verify_signature=False) context["access_token_parsed"] = access_token_parsed - context["resource_providers"] = registry.resource_providers + context["resources"] = registry.resources + context["resource_providers"] = provider.resource_providers try: context["refresh_token_parsed"] = provider.decode( token["refresh_token"], verify_signature=False @@ -246,7 +246,7 @@ async def logout( if ( provider_logout_uri := provider.authlib_client.server_metadata.get("end_session_endpoint") ) is None: - logger.warn(f"Cannot find end_session_endpoint for provider {provider.id}") + logger.warning(f"Cannot find end_session_endpoint for provider {provider.id}") return RedirectResponse(request.url_for("non_compliant_logout")) post_logout_uri = request.url_for("home") # Clear session @@ -255,19 +255,15 @@ async def logout( try: token = await db.get_token(provider, request.session.pop("sid", None)) except TokenNotInDb: - logger.warn("No session in db for the token or no token") + logger.warning("No session in db for the token or no token") return RedirectResponse(request.url_for("home")) - logout_url = ( - provider_logout_uri - + "?" - + urlencode( - { - "post_logout_redirect_uri": post_logout_uri, - "id_token_hint": token["id_token"], - "client_id": "oidc_local_test", - } - ) - ) + url_query = { + "post_logout_redirect_uri": post_logout_uri, + "client_id": provider.client_id, + } + if provider.logout_with_id_token_hint: + url_query["id_token_hint"] = token["id_token"] + logout_url = f"{provider_logout_uri}?{urlencode(url_query)}" return RedirectResponse(logout_url) diff --git a/src/oidc_test/registry.py b/src/oidc_test/registry.py index 794a843..3b91ad4 100644 --- a/src/oidc_test/registry.py +++ b/src/oidc_test/registry.py @@ -18,7 +18,7 @@ class ProcessError(Exception): pass -class ResourceProvider(BaseModel): +class Resource(BaseModel): name: str scope_required: str | None = None role_required: str | None = None @@ -35,13 +35,13 @@ class ResourceProvider(BaseModel): class ResourceRegistry(BaseModel): - resource_providers: dict[str, ResourceProvider] = {} + resources: dict[str, Resource] = {} def make_registry(self): for ep in entry_points().select(group="oidc_test.resource_provider"): - ResourceProviderClass = ep.load() - if issubclass(ResourceProviderClass, ResourceProvider): - self.resource_providers[ep.name] = ResourceProviderClass(ep.name) + ResourceClass = ep.load() + if issubclass(ResourceClass, Resource): + self.resources[ep.name] = ResourceClass(ep.name) registry = ResourceRegistry() diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py index ee4ff10..a4d5368 100644 --- a/src/oidc_test/resource_server.py +++ b/src/oidc_test/resource_server.py @@ -17,7 +17,7 @@ from oidc_test.auth.utils import ( oauth2_scheme_optional, ) from oidc_test.auth_providers import providers -from oidc_test.settings import settings +from oidc_test.settings import ResourceProvider, settings from oidc_test.models import User from oidc_test.registry import ProcessError, ProcessResult, registry @@ -48,7 +48,7 @@ resource_server.add_middleware( @resource_server.get("/") async def resources() -> dict[str, dict[str, Any]]: - return {"internal": {}, "plugins": registry.resource_providers} + return {"internal": {}, "plugins": registry.resources} @resource_server.get("/{resource_name}") @@ -65,8 +65,41 @@ async def get_resource( # Get the resource if it's defined in user auth provider's resources (external) if user is not None: provider = providers[user.auth_provider_id] + if ":" in resource_name: + # Third-party resource provider: send the request with the request token + resource_provider_id, resource_name = resource_name.split(":", 1) + provider = providers[user.auth_provider_id] + resource_provider: ResourceProvider = provider.get_resource_provider( + resource_provider_id + ) + resource_url = resource_provider.get_resource_url(resource_name) + async with AsyncClient(verify=resource_provider.verify_ssl) as client: + try: + resp = await client.get( + resource_url, + headers={ + "Content-type": "application/json", + "Authorization": f"Bearer {token}", + "auth_provider": user.auth_provider_id, + }, + ) + except Exception as err: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, err.__class__.__name__ + ) + else: + if resp.is_success: + return resp.json() + else: + reason_str: str + try: + reason_str = resp.json().get("detail", str(resp)) + except Exception: + reason_str = str(resp.text) + raise HTTPException(resp.status_code, reason_str) # Third party resource (provided through the auth provider) # The token is just passed on + # XXX: is this branch valid anymore? if resource_name in [r.resource_name for r in provider.resources]: return await get_auth_provider_resource( provider=provider, @@ -75,31 +108,31 @@ async def get_resource( user=user, ) # Internal resource (provided here) - if resource_name in registry.resource_providers: - resource_provider = registry.resource_providers[resource_name] + if resource_name in registry.resources: + resource = registry.resources[resource_name] reason: dict[str, str] = {} - if not resource_provider.is_public: + if not resource.is_public: if user is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Resource is not public") else: - if resource_provider.scope_required is not None and not user.has_scope( - resource_provider.scope_required + if resource.scope_required is not None and not user.has_scope( + resource.scope_required ): reason["scope"] = ( - f"No scope {resource_provider.scope_required} in the access token " + f"No scope {resource.scope_required} in the access token " "but it is required for accessing this resource" ) if ( - resource_provider.role_required is not None - and resource_provider.role_required not in user.roles_as_set + resource.role_required is not None + and resource.role_required not in user.roles_as_set ): reason["role"] = ( - f"You don't have the role {resource_provider.role_required} " + f"You don't have the role {resource.role_required} " "but it is required for accessing this resource" ) if len(reason) == 0: try: - resp = await resource_provider.process(user=user, resource_id=resource_id) + resp = await resource.process(user=user, resource_id=resource_id) return resp except ProcessError as err: raise HTTPException( @@ -116,12 +149,11 @@ async def get_auth_provider_resource( provider: Provider, resource_name: str, token: OAuth2Token | None, user: User ) -> ProcessResult: if token is None: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"No auth token") + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No auth token") access_token = token["access_token"] - resource = [r for r in provider.resources if r.resource_name == resource_name][0] async with AsyncClient() as client: resp = await client.get( - url=provider.url + resource.url, + url=provider.get_resource_url(resource_name), headers={ "Content-type": "application/json", "Authorization": f"Bearer {access_token}", diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py index 3e7001c..e549fd4 100644 --- a/src/oidc_test/settings.py +++ b/src/oidc_test/settings.py @@ -4,7 +4,7 @@ import random from typing import Type, Tuple from pathlib import Path -from pydantic import BaseModel, computed_field, AnyUrl +from pydantic import AnyHttpUrl, BaseModel, computed_field, AnyUrl from pydantic_settings import ( BaseSettings, SettingsConfigDict, @@ -22,6 +22,22 @@ class Resource(BaseModel): url: str +class ResourceProvider(BaseModel): + id: str + name: str + base_url: AnyUrl + resources: list[Resource] = [] + verify_ssl: bool = True + + def get_resource(self, resource_name: str) -> Resource: + return [ + resource for resource in self.resources if resource.resource_name == resource_name + ][0] + + def get_resource_url(self, resource_name: str) -> str: + return f"{self.base_url}{self.get_resource(resource_name).url}" + + class AuthProviderSettings(BaseModel): """Auth provider, can also be a resource server""" @@ -45,6 +61,7 @@ class AuthProviderSettings(BaseModel): session_key: str = "sid" skip_verify_signature: bool = True disabled: bool = False + resource_providers: list[ResourceProvider] = [] @computed_field @property @@ -67,13 +84,6 @@ class AuthProviderSettings(BaseModel): return None -class ResourceProvider(BaseModel): - id: str - name: str - base_url: AnyUrl - resources: list[Resource] = [] - - class AuthSettings(BaseModel): show_session_details: bool = False providers: list[AuthProviderSettings] = [] @@ -92,13 +102,13 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_nested_delimiter="__") auth: AuthSettings = AuthSettings() - resource_providers: list[ResourceProvider] = [] secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16)) log: bool = False insecure: Insecure = Insecure() cors_origins: list[str] = [] debug_token: bool = False show_token: bool = False + show_external_resource_providers_links: bool = False @classmethod def settings_customise_sources( diff --git a/src/oidc_test/static/utils.js b/src/oidc_test/static/utils.js index 978b61c..e988dfe 100644 --- a/src/oidc_test/static/utils.js +++ b/src/oidc_test/static/utils.js @@ -2,7 +2,9 @@ async function checkHref(elem, token, authProvider) { const msg = document.getElementById("msg") const resourceName = elem.getAttribute("resource-name") const resourceId = elem.getAttribute("resource-id") - const url = resourceId ? `resource/${resourceName}/${resourceId}` : `resource/${resourceName}` + const resourceProviderId = elem.getAttribute("resource-provider-id") ? elem.getAttribute("resource-provider-id") : "" + const fqResourceName = resourceProviderId ? `${resourceProviderId}:${resourceName}` : resourceName + const url = resourceId ? `resource/${fqResourceName}/${resourceId}` : `resource/${fqResourceName}` const resp = await fetch(url, { method: "GET", headers: new Headers({ @@ -30,11 +32,13 @@ function checkPerms(className, token, authProvider) { ) } -async function get_resource(resource_name, token, authProvider, resource_id) { +async function get_resource(resourceName, token, authProvider, resourceId, resourceProviderId) { + // BaseUrl for an external resource provider //if (!keycloak.keycloak) { return } const msg = document.getElementById("msg") const resourceElem = document.getElementById('resource') - const url = resource_id ? `resource/${resource_name}/${resource_id}` : `resource/${resource_name}` + const fqResourceName = resourceProviderId ? `${resourceProviderId}:${resourceName}` : resourceName + const url = resourceId ? `resource/${fqResourceName}/${resourceId}` : `resource/${fqResourceName}` const resp = await fetch(url, { method: "GET", headers: new Headers({ diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html index 6c4e6a6..5bccaee 100644 --- a/src/oidc_test/templates/home.html +++ b/src/oidc_test/templates/home.html @@ -66,29 +66,50 @@ {% endif %}
- {% if resource_providers %} + + {% if resources %}

{{ auth_provider.name }} provides these resources:

{% endif %} + {% if resource_providers %} +

{{ auth_provider.name }} can request resources from third party resource providers:

+ {% for resource_provider in resource_providers %} + + {% endfor %} + {% endif %}
From e925f2176258d4f73b5b7e565123652da02d6b12 Mon Sep 17 00:00:00 2001 From: phil Date: Thu, 20 Feb 2025 02:01:18 +0100 Subject: [PATCH 10/37] Add configurable logging from settings --- log_conf.yaml => src/oidc_test/log_conf.yaml | 0 src/oidc_test/main.py | 13 +++++++++++++ src/oidc_test/settings.py | 1 + 3 files changed, 14 insertions(+) rename log_conf.yaml => src/oidc_test/log_conf.yaml (100%) diff --git a/log_conf.yaml b/src/oidc_test/log_conf.yaml similarity index 100% rename from log_conf.yaml rename to src/oidc_test/log_conf.yaml diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py index f930e48..cf28f27 100644 --- a/src/oidc_test/main.py +++ b/src/oidc_test/main.py @@ -6,6 +6,9 @@ from typing import Annotated from pathlib import Path from datetime import datetime import logging +import logging.config +import importlib.resources +from yaml import safe_load from urllib.parse import urlencode from contextlib import asynccontextmanager @@ -46,6 +49,16 @@ from oidc_test.resource_server import resource_server logger = logging.getLogger("oidc-test") +if settings.log: + assert __package__ is not None + with ( + importlib.resources.path(__package__) as package_path, + open(package_path / settings.log_config_file) as f, + ): + logging_config = safe_load(f) + logging.config.dictConfig(logging_config) + +breakpoint() templates = Jinja2Templates(Path(__file__).parent / "templates") diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py index e549fd4..ad80c06 100644 --- a/src/oidc_test/settings.py +++ b/src/oidc_test/settings.py @@ -104,6 +104,7 @@ class Settings(BaseSettings): auth: AuthSettings = AuthSettings() secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16)) log: bool = False + log_config_file: str = "log_conf.yaml" insecure: Insecure = Insecure() cors_origins: list[str] = [] debug_token: bool = False From 703985f31102705769e725b25d54dc8414bb0610 Mon Sep 17 00:00:00 2001 From: phil Date: Thu, 20 Feb 2025 02:01:33 +0100 Subject: [PATCH 11/37] Add configurable logging from settings --- src/oidc_test/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py index cf28f27..8fe32d8 100644 --- a/src/oidc_test/main.py +++ b/src/oidc_test/main.py @@ -58,7 +58,6 @@ if settings.log: logging_config = safe_load(f) logging.config.dictConfig(logging_config) -breakpoint() templates = Jinja2Templates(Path(__file__).parent / "templates") From 0764b1c003151ddbf7afe6d6d17590aca0913de2 Mon Sep 17 00:00:00 2001 From: phil Date: Thu, 20 Feb 2025 02:05:15 +0100 Subject: [PATCH 12/37] Log request to resource server --- src/oidc_test/resource_server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py index a4d5368..604052c 100644 --- a/src/oidc_test/resource_server.py +++ b/src/oidc_test/resource_server.py @@ -75,6 +75,7 @@ async def get_resource( resource_url = resource_provider.get_resource_url(resource_name) async with AsyncClient(verify=resource_provider.verify_ssl) as client: try: + logger.debug(f"GET request to {resource_url}") resp = await client.get( resource_url, headers={ From d924c56b1710082690df1e6fd8a146e8f672cf6d Mon Sep 17 00:00:00 2001 From: phil Date: Thu, 20 Feb 2025 02:56:28 +0100 Subject: [PATCH 13/37] Cosmetic --- src/oidc_test/templates/home.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html index 5bccaee..7d2b1db 100644 --- a/src/oidc_test/templates/home.html +++ b/src/oidc_test/templates/home.html @@ -70,7 +70,7 @@ --> {% if resources %}

- {{ auth_provider.name }} provides these resources: + This application provides all these resources, eventually protected with roles:

{% endif %} {% if resource_providers %} -

{{ auth_provider.name }} can request resources from third party resource providers:

+

{{ auth_provider.name }} allows this applicaiton to request resources from third party resource providers:

{% for resource_provider in resource_providers %}