From 3eb6dc3dcf4be7350aa4cda6b3157183fe77d5d8 Mon Sep 17 00:00:00 2001 From: phil Date: Fri, 7 Feb 2025 16:09:49 +0100 Subject: [PATCH] Migrate all resources to json contents; improve token decoding & logging error messages --- src/oidc_test/auth_utils.py | 17 ++++++++---- src/oidc_test/resource_server.py | 43 +++++++++++++++-------------- src/oidc_test/settings.py | 46 ++++++++++++------------------- src/oidc_test/static/styles.css | 10 ++----- src/oidc_test/static/utils.js | 3 +- src/oidc_test/templates/home.html | 45 ++++++++++++++---------------- 6 files changed, 77 insertions(+), 87 deletions(-) diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py index 1004527..3303e58 100644 --- a/src/oidc_test/auth_utils.py +++ b/src/oidc_test/auth_utils.py @@ -5,7 +5,7 @@ import logging from fastapi import HTTPException, Request, Depends, status from fastapi.security import OAuth2PasswordBearer from authlib.integrations.starlette_client import OAuth, OAuthError, StarletteOAuth2App -from jwt import ExpiredSignatureError, InvalidKeyError +from jwt import ExpiredSignatureError, InvalidKeyError, DecodeError from httpx import AsyncClient # from authlib.oauth1.auth import OAuthToken @@ -147,8 +147,8 @@ async def get_token(request: Request) -> OAuth2Token: oidc_provider_settings, request.session.get("sid"), ) - except (TokenNotInDb, InvalidKeyError): - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No token") + except (TokenNotInDb, InvalidKeyError, DecodeError) as err: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, err.__class__.__name__) async def get_current_user_or_none(request: Request) -> User | None: @@ -208,9 +208,14 @@ async def get_user_from_token( try: auth_provider_settings = oidc_providers_settings[auth_provider_id] except KeyError: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, f"Unknown auth provider '{auth_provider_id}'" - ) + if auth_provider_id == "": + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No auth provider") + else: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, f"Unknown auth provider '{auth_provider_id}'" + ) + if token == "": + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No token") try: payload = auth_provider_settings.decode(token) except ExpiredSignatureError as err: diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py index d5e2aaa..cb944ed 100644 --- a/src/oidc_test/resource_server.py +++ b/src/oidc_test/resource_server.py @@ -4,8 +4,7 @@ import logging from httpx import AsyncClient from jwt.exceptions import ExpiredSignatureError, InvalidTokenError -from fastapi import FastAPI, HTTPException, Depends, Request, status -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi import FastAPI, HTTPException, Depends, status from fastapi.middleware.cors import CORSMiddleware # from starlette.middleware.sessions import SessionMiddleware @@ -16,8 +15,8 @@ from .models import User from .auth_utils import ( get_user_from_token, UserWithRole, - get_oidc_provider, - get_token, + # get_oidc_provider, + # get_token, ) from .settings import settings @@ -47,44 +46,46 @@ resource_server.add_middleware( @resource_server.get("/public") -async def public() -> HTMLResponse: - return HTMLResponse("

Not protected

") +async def public() -> dict: + return {"msg": "Not protected"} @resource_server.get("/protected") -async def get_protected(user: Annotated[User, Depends(get_user_from_token)]) -> HTMLResponse: +async def get_protected(user: Annotated[User, Depends(get_user_from_token)]): assert user is not None # Just to keep QA checks happy - return HTMLResponse("

Only authenticated users can see this

") + 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"))] -) -> HTMLResponse: - return HTMLResponse("

Only users with foorole can see this

") + 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"))] -) -> HTMLResponse: - return HTMLResponse("

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"))], -) -> HTMLResponse: +): assert user is not None # Just to keep QA checks happy - return HTMLResponse("

Only users with foorole and barrole can see this

") + 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"]))] -) -> HTMLResponse: + user: Annotated[User, Depends(UserWithRole(["foorole", "barrole"]))], +): assert user is not None # Just to keep QA checks happy - return HTMLResponse("

Only users with foorole or barrole can see this

") + return {"msg": "Only users with foorole or barrole can see this"} # @resource_server.get("/introspect") @@ -118,9 +119,9 @@ async def get_resource_( # oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)], # token: Annotated[OAuth2Token, Depends(get_token)], user: Annotated[User, Depends(get_user_from_token)], -) -> JSONResponse: +): """Generic path for testing a resource provided by a provider""" - return JSONResponse(await get_resource(id, user)) + return await get_resource(id, user) async def get_resource(resource_id: str, user: User) -> dict: diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py index 2544bd7..b601739 100644 --- a/src/oidc_test/settings.py +++ b/src/oidc_test/settings.py @@ -43,9 +43,7 @@ class OIDCProvider(BaseModel): info_url: str | None = ( None # Used eg. for Keycloak's public key (see https://stackoverflow.com/questions/54318633/getting-keycloaks-public-key) ) - info: dict[str, str | int] | None = ( - None # Info fetched from info_url, eg. public key - ) + info: dict[str, str | int] | None = None # Info fetched from info_url, eg. public key public_key: str | None = None signature_alg: str = "RS256" resource_provider_scopes: list[str] = [] @@ -62,25 +60,17 @@ class OIDCProvider(BaseModel): def get_account_url(self, request: Request, user: User) -> str | None: if self.account_url_template: - if not ( - self.url.endswith("/") or self.account_url_template.startswith("/") - ): + if not (self.url.endswith("/") or self.account_url_template.startswith("/")): sep = "/" else: sep = "" - return ( - self.url - + sep - + self.account_url_template.format(request=request, user=user) - ) + return self.url + sep + self.account_url_template.format(request=request, user=user) else: return None def get_public_key(self) -> str: """Return the public key formatted for decoding token""" - public_key = self.public_key or ( - self.info is not None and self.info["public_key"] - ) + public_key = self.public_key or (self.info is not None and self.info["public_key"]) if public_key is None: raise AttributeError(f"Cannot get public key for {self.name}") return f""" @@ -91,17 +81,18 @@ class OIDCProvider(BaseModel): def decode(self, token: str, verify_signature: bool = True) -> dict[str, Any]: """Decode the token with signature check""" - decoded = decode( - token, - self.get_public_key(), - algorithms=[self.signature_alg], - audience=["account", "oidc-test", "oidc-test-web"], - options={ - "verify_signature": False, - "verify_aud": False, - }, # not settings.insecure.skip_verify_signature}, - ) - logger.debug(str(decoded)) + if settings.debug_token: + decoded = decode( + token, + self.get_public_key(), + algorithms=[self.signature_alg], + audience=["account", "oidc-test", "oidc-test-web"], + options={ + "verify_signature": False, + "verify_aud": False, + }, # not settings.insecure.skip_verify_signature}, + ) + logger.debug(str(decoded)) return decode( token, self.get_public_key(), @@ -143,6 +134,7 @@ class Settings(BaseSettings): log: bool = False insecure: Insecure = Insecure() cors_origins: list[str] = [] + debug_token: bool = False @classmethod def settings_customise_sources( @@ -161,9 +153,7 @@ class Settings(BaseSettings): settings_cls, Path( Path( - environ.get( - "OIDC_TEST_SETTINGS_FILE", Path.cwd() / "settings.yaml" - ), + environ.get("OIDC_TEST_SETTINGS_FILE", Path.cwd() / "settings.yaml"), ) ), ), diff --git a/src/oidc_test/static/styles.css b/src/oidc_test/static/styles.css index 7e1260b..6262d79 100644 --- a/src/oidc_test/static/styles.css +++ b/src/oidc_test/static/styles.css @@ -171,11 +171,13 @@ hr { gap: 0.5em; flex-flow: wrap; } -.content .links-to-check a { +.content .links-to-check button { color: black; padding: 5px 10px; text-decoration: none; border-radius: 8px; + border: none; + cursor: pointer; } .token { @@ -183,12 +185,6 @@ hr { font-family: monospace; } -.actions { - display: flex; - justify-content: center; - gap: 0.5em; -} - .resourceResult { padding: 0.5em; gap: 0.5em; diff --git a/src/oidc_test/static/utils.js b/src/oidc_test/static/utils.js index e6c4bfc..6ea8da2 100644 --- a/src/oidc_test/static/utils.js +++ b/src/oidc_test/static/utils.js @@ -1,6 +1,7 @@ async function checkHref(elem, token, authProvider) { const msg = document.getElementById("msg") - const resp = await fetch(elem.href, { + const url = `resource/${elem.getAttribute("resource-id")}` + const resp = await fetch(url, { headers: new Headers({ "Content-type": "application/json", "Authorization": `Bearer ${token}`, diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html index 09c313f..92b7068 100644 --- a/src/oidc_test/templates/home.html +++ b/src/oidc_test/templates/home.html @@ -61,33 +61,30 @@ {% endif %}
- {% if user %} -

- Fetch resources from the resource server with your authentication token: -

-
- - -
-
-
-
-
-
- {% endif %}
-

- These links should get different response codes depending on the authorization: +

+ Resources validated by scope:

+

+ Resources validated by role: +

+ +
+
+
{% if resources %}