From c89ca4098b2165014890af89caebde7310b88db0 Mon Sep 17 00:00:00 2001 From: phil Date: Fri, 14 Feb 2025 13:21:55 +0100 Subject: [PATCH 01/32] 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 02/32] 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 03/32] 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 04/32] 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 05/32] 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 06/32] 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 07/32] 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 08/32] 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 %}