From 5cc5473eaed2ecad1d9e9da9851b4b891992fab2 Mon Sep 17 00:00:00 2001 From: Kip Parker Date: Fri, 19 Apr 2024 15:28:10 +0100 Subject: [PATCH 1/3] Removes reduntant code, fixes introspection endpoint, fixes local dev environment --- authentication/api/auth.py | 10 +++-- authentication/api/conf.py | 2 +- authentication/api/examples.py | 5 +++ authentication/api/main.py | 82 +++++++++++++++++++++++++--------- authentication/api/models.py | 14 +++++- authentication/api/par.py | 8 +++- client.py | 44 ------------------ compose.yml | 9 ++-- resource/api/auth.py | 14 +++++- resource/api/conf.py | 6 ++- resource/api/main.py | 1 + 11 files changed, 115 insertions(+), 80 deletions(-) diff --git a/authentication/api/auth.py b/authentication/api/auth.py index 6dc1dc6..4fc03ed 100644 --- a/authentication/api/auth.py +++ b/authentication/api/auth.py @@ -93,6 +93,7 @@ def introspect(client_certificate: str, token: str) -> dict: 6. Certificate binding is enabled (default) and the fingerprint of the presented client cert isn't a match for the claim in the introspection response + 7. Client ID does not match If introspection succeeds, return a dict suitable to use as headers including Date and x-fapi-interaction-id, as well as the introspection response @@ -103,9 +104,12 @@ def introspect(client_certificate: str, token: str) -> dict: if cert is None: log.warning("no client cert presented") raise AccessTokenNoCertificateError("No client certificate presented") - introspection_response = jwt.decode( - token, algorithms=["ES256"], options={"verify_signature": False} - ) + try: + introspection_response = jwt.decode( + token, algorithms=["ES256"], options={"verify_signature": False} + ) + except jwt.exceptions.DecodeError as e: + raise AccessTokenValidatorError(f"Invalid token {str(e)} for token {token}") introspection_response["active"] = True log.debug(f"introspection response {introspection_response}") diff --git a/authentication/api/conf.py b/authentication/api/conf.py index 2ffbc10..c1d6679 100644 --- a/authentication/api/conf.py +++ b/authentication/api/conf.py @@ -14,7 +14,7 @@ } -ISSUER_URL = os.environ.get("ISSUER_URL", "https://perseus-demo-energy.ib1.org") +ISSUER_URL = os.environ.get("ISSUER_URL", "https://perseus-demo-authentication.ib1.org") CLIENT_ID = os.environ.get("CLIENT_ID", "21653835348762") CLIENT_SECRET = os.environ.get( "CLIENT_SECRET", "uE4NgqeIpuSV_XejQ7Ds3jsgA1yXhjR1MXJ1LbPuyls" diff --git a/authentication/api/examples.py b/authentication/api/examples.py index 99a2f18..ccae432 100644 --- a/authentication/api/examples.py +++ b/authentication/api/examples.py @@ -57,6 +57,11 @@ "refresh_token": "tXZjYfoK35I-djg9V3n6s58zsrVqRIzTNMXKIS_wkj8", } +INTROSPECTION_REQUEST: JsonDict = { + "token": "SUtEVc3Tj3D3xOdysQtssQxe9egAhI4fimexNVMjRyU", + "client_certificate": CLIENT_CERTIFICATE, +} +INTROSPECTION_FAILED_RESPONSE: JsonDict = {"active": False} INTROSPECTION_RESPONSE = { "aud": [], "client_id": "f67916ce-de33-4e2f-a8e3-cbd5f6459c30", diff --git a/authentication/api/main.py b/authentication/api/main.py index 7b013ef..7fb050d 100644 --- a/authentication/api/main.py +++ b/authentication/api/main.py @@ -1,11 +1,19 @@ from typing import Annotated import json +import secrets import requests import jwt -from fastapi import FastAPI, Header, HTTPException, status, Form, Request -from fastapi.security import HTTPBasic +from fastapi import ( + FastAPI, + Header, + HTTPException, + status, + Form, + Depends, +) +from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.responses import Response @@ -24,6 +32,35 @@ security = HTTPBasic() +def get_authentication_credentials( + credentials: Annotated[HTTPBasicCredentials, Depends(security)] +): + """ + The resource server will use client credentials to connect to the introspection endpoint + """ + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"error": "Authorization required"}, + headers={"WWW-Authenticate": "Basic"}, + ) + current_username_bytes = credentials.username.encode("utf8") + is_correct_username = secrets.compare_digest( + current_username_bytes, conf.CLIENT_ID.encode("utf8") + ) + current_password_bytes = credentials.password.encode("utf8") + is_correct_password = secrets.compare_digest( + current_password_bytes, conf.CLIENT_SECRET.encode("utf8") + ) + if not (is_correct_username and is_correct_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"error": "Invalid credentials"}, + headers={"WWW-Authenticate": "Basic"}, + ) + return credentials.username + + @app.get("/") async def docs() -> dict: return {"docs": "/api-docs"} @@ -31,7 +68,6 @@ async def docs() -> dict: @app.post("/api/v1/par", response_model=models.PushedAuthorizationResponse) async def pushed_authorization_request( - request: Request, response_type: Annotated[str, Form()], client_id: Annotated[str, Form()], redirect_uri: Annotated[str, Form()], @@ -180,34 +216,38 @@ async def token( ) -@app.post("/api/v1/authorize/introspect", response_model=models.IntrospectionResponse) -async def introspect( - token: models.IntrospectionRequest, - x_amzn_mtls_clientcert: Annotated[str, Header()], +@app.post( + "/api/v1/authorize/introspect", + response_model=models.IntrospectionResponse | models.IntrospectionFailedResponse, +) +async def introspection( + client_id: Annotated[str, Depends(get_authentication_credentials)], + introspection_request: models.IntrospectionRequest, ) -> dict: - """ - We have our token as a jwt, so we can do the introspection here - """ - if x_amzn_mtls_clientcert is None: - raise HTTPException(status_code=401, detail="No client certificate provided") + if not introspection_request.client_certificate: + raise HTTPException( + status_code=400, + detail="Client certificate required", + ) try: - introspection_response = auth.introspect(x_amzn_mtls_clientcert, token.token) + response = auth.introspect( + introspection_request.client_certificate, introspection_request.token + ) except auth.AccessTokenValidatorError as e: - raise HTTPException(status_code=401, detail=str(e)) - # The authentication server can use the response to make its own checks, - return introspection_response + print(str(e)) + response = {"active": False} + return response @app.get("/.well-known/openid-configuration") async def get_openid_configuration(): - return { "issuer": f"{conf.ISSUER_URL}", - "pushed_authorization_request_endpoint": f"{conf.ISSUER_URL}/auth/par/", - "authorization_endpoint": f"{conf.ISSUER_URL}/auth/authorization/", - "token_endpoint": f"{conf.ISSUER_URL}/auth/token/", + "pushed_authorization_request_endpoint": f"{conf.ISSUER_URL}/api/v1/par", + "authorization_endpoint": f"{conf.ISSUER_URL}/api/v1/authorize", + "token_endpoint": f"{conf.ISSUER_URL}/api/v1/authorize/token", "jwks_uri": f"{conf.ISSUER_URL}/.well-known/jwks.json", - "introspection_endpoint": f"{conf.ISSUER_URL}/auth/introspection/", + "introspection_endpoint": f"{conf.ISSUER_URL}/api/v1/authorize/introspect", "response_types_supported": ["code", "id_token", "token"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["ES256"], diff --git a/authentication/api/models.py b/authentication/api/models.py index 3930a02..c31877f 100644 --- a/authentication/api/models.py +++ b/authentication/api/models.py @@ -89,10 +89,22 @@ class TokenResponse(BaseModel): class IntrospectionRequest(BaseModel): token: str + client_certificate: str + model_config = { + "json_schema_extra": { + "examples": [ + examples.INTROSPECTION_REQUEST, + ] + } + } + + +class IntrospectionFailedResponse(BaseModel): + active: bool model_config = { "json_schema_extra": { "examples": [ - {"token": "SUtEVc3T"}, + examples.INTROSPECTION_FAILED_RESPONSE, ] } } diff --git a/authentication/api/par.py b/authentication/api/par.py index c53a199..4d68c54 100644 --- a/authentication/api/par.py +++ b/authentication/api/par.py @@ -22,7 +22,11 @@ def store_request(token: str, request: dict): connection.expire(token, 60) # 1 minute -def get_request(token: str) -> dict: +def get_request(token: str) -> dict | None: connection = redis_connection() request = connection.get(token) - return json.loads(str(request)) + try: + data = json.loads(str(request)) + except json.decoder.JSONDecodeError: + return None + return data diff --git a/client.py b/client.py index 64d5af7..6dd0d32 100644 --- a/client.py +++ b/client.py @@ -65,50 +65,6 @@ def pushed_authorization_request() -> tuple[str, dict]: return code_verifier, response.json() -def initiate_authorization(request_uri: str): - """ - /as/authorise?request_uri=urn:ietf:params:oauth:request_uri:UymBrux4ZEMrBRKx9UyKyIm98zpX1cHmAPGAGNofmm4 - """ - response = requests.post( - f"{AUTHENTICATION_API}/api/v1/authorize", - json={ - "request_uri": request_uri, - "client_id": f"{conf.CLIENT_ID}", - }, - verify=False, - ) - return response.json() - - -def get_user_token(): - response = requests.post( - f"{AUTHENTICATION_API}/api/v1/authenticate/token", - data={"username": "platform_user", "password": "perseus"}, - verify=False, - ) - return response.json() - - -def authentication_issue_request(token: str, ticket: str): - response = requests.post( - f"{AUTHENTICATION_API}/api/v1/authorize/issue", - json={"ticket": ticket}, - headers={"Authorization": "Bearer " + token}, - verify=False, - ) - return response.json() - - -def give_consent(token: str): - response = requests.post( - f"{AUTHENTICATION_API}/api/v1/authenticate/consent", - json={"scopes": ["account"]}, - headers={"Authorization": "Bearer " + token}, - verify=False, - ) - return response.json() - - def introspect_token(fapi_token: str): session = get_session() # session = requests.Session() diff --git a/compose.yml b/compose.yml index b02f0be..0af11b6 100644 --- a/compose.yml +++ b/compose.yml @@ -17,11 +17,11 @@ services: build: context: authentication stop_signal: SIGINT - env_file: - - authentication/.env + env_file: authentication/.env environment: + - FAPI_API=http://host.docker.internal:8020 - REDIS_HOST=redis - - CALLBACK_URL=http://host.docker.internal:3000/callback + # - FAPI_API=https://perseus-demo-fapi.ib1.org command: [ "uvicorn", @@ -51,8 +51,9 @@ services: build: context: resource stop_signal: SIGINT + env_file: resource/.env environment: - - ISSUER_URL=http://host.docker.internal:8020 + - ISSUER_URL=https://host.docker.internal:8000 # As accounting app is outside docker we'll need to use this address command: [ "uvicorn", diff --git a/resource/api/auth.py b/resource/api/auth.py index 640bf3c..8015226 100644 --- a/resource/api/auth.py +++ b/resource/api/auth.py @@ -27,9 +27,9 @@ def get_openid_configuration(issuer_url: str) -> dict: """ Get the well-known configuration for a given issuer URL """ - print(urllib.parse.urljoin(issuer_url, "/.well-known/openid-configuration")) response = requests.get( url=urllib.parse.urljoin(issuer_url, "/.well-known/openid-configuration"), + verify=False, ) response.raise_for_status() return response.json() @@ -37,11 +37,20 @@ def get_openid_configuration(issuer_url: str) -> dict: def check_token(token: str, client_certificate: str) -> dict: openid_config = get_openid_configuration(conf.ISSUER_URL) + introspection_endpoint = openid_config["introspection_endpoint"] + if ( + "localhost" in introspection_endpoint + ): # Bit of messing about for docker, consider bringing accounting app into the same project + introspection_endpoint = introspection_endpoint.replace( + "https://localhost:8000", conf.ISSUER_URL + ) + log.debug("Token type", type(token)) try: response = requests.post( - url=openid_config["introspection_endpoint"], + url=introspection_endpoint, json={"token": token, "client_certificate": client_certificate}, auth=(conf.CLIENT_ID, conf.CLIENT_SECRET), + verify=False, ) if response.status_code != 200: log.error(f"introspection request failed: {response.text}") @@ -50,6 +59,7 @@ def check_token(token: str, client_certificate: str) -> dict: except requests.exceptions.RequestException as e: log.error(f"introspection request failed: {e}") raise AccessTokenValidatorError("Introspection request failed") + print(response.json()) return response.json() diff --git a/resource/api/conf.py b/resource/api/conf.py index 83da13f..c7811db 100644 --- a/resource/api/conf.py +++ b/resource/api/conf.py @@ -3,6 +3,8 @@ ENV = os.environ.get("ENV", "dev") DIRNAME = os.path.dirname(os.path.realpath(__file__)) ISSUER_URL = os.environ.get("ISSUER_URL", "") -CLIENT_ID = "21653835348762" -CLIENT_SECRET = "uE4NgqeIpuSV_XejQ7Ds3jsgA1yXhjR1MXJ1LbPuyls" +CLIENT_ID = os.environ.get("CLIENT_ID", "21653835348762") +CLIENT_SECRET = os.environ.get( + "CLIENT_SECRET", "uE4NgqeIpuSV_XejQ7Ds3jsgA1yXhjR1MXJ1LbPuyls" +) OPEN_API_ROOT = "/dev" if ENV == "prod" else "" diff --git a/resource/api/main.py b/resource/api/main.py index c96186f..a64306b 100644 --- a/resource/api/main.py +++ b/resource/api/main.py @@ -56,6 +56,7 @@ def consumption( x_amzn_mtls_clientcert: Annotated[str | None, Header()] = None, x_fapi_interaction_id: Annotated[str | None, Header()] = None, ): + print(token) if x_amzn_mtls_clientcert is None: raise HTTPException(status_code=401, detail="No client certificate provided") if token and token.credentials: From 75bdfea608e220ccb2a0fbf278748db822f7b4e5 Mon Sep 17 00:00:00 2001 From: Kip Parker Date: Tue, 23 Apr 2024 07:26:55 +0100 Subject: [PATCH 2/3] Tidy up print statements --- resource/api/auth.py | 1 - resource/api/main.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/resource/api/auth.py b/resource/api/auth.py index 8015226..6081e6a 100644 --- a/resource/api/auth.py +++ b/resource/api/auth.py @@ -59,7 +59,6 @@ def check_token(token: str, client_certificate: str) -> dict: except requests.exceptions.RequestException as e: log.error(f"introspection request failed: {e}") raise AccessTokenValidatorError("Introspection request failed") - print(response.json()) return response.json() diff --git a/resource/api/main.py b/resource/api/main.py index a64306b..73cf562 100644 --- a/resource/api/main.py +++ b/resource/api/main.py @@ -56,7 +56,6 @@ def consumption( x_amzn_mtls_clientcert: Annotated[str | None, Header()] = None, x_fapi_interaction_id: Annotated[str | None, Header()] = None, ): - print(token) if x_amzn_mtls_clientcert is None: raise HTTPException(status_code=401, detail="No client certificate provided") if token and token.credentials: @@ -64,7 +63,6 @@ def consumption( _, headers = auth.introspect( x_amzn_mtls_clientcert, token.credentials, x_fapi_interaction_id ) - print(headers) except auth.AccessTokenValidatorError as e: raise HTTPException(status_code=401, detail=str(e)) else: From f6523458b34ed57d434fa79c05ae8d7612c36175 Mon Sep 17 00:00:00 2001 From: Kip Parker Date: Tue, 23 Apr 2024 16:39:35 +0100 Subject: [PATCH 3/3] Remove references to old FAPI_API urls --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a423970..c871859 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ The included docker compose file will bring up both APIs. It uses nginx to proxy docker-compose up ``` -The environment variables in the docker compose file point to the FAPI api running on localhost port 8020 (http://host.docker.internal:8020). As the FAPI api is not running in the docker environment, you may need to change these environment variables to match your local environment. It will also work with the live FAPI api by changing these values to "https://perseus-demo-fapi.ib1.org". +The environment variables in the docker compose file point to the FAPI api running on localhost port 8020 (http://host.docker.internal:8020). As the FAPI api is not running in the docker environment, you may need to change these environment variables to match your local environment. It will also work with the live FAPI api by changing these values to "https://perseus-demo-authentication.ib1.org". ## Pushed Authorization Request (PAR) @@ -83,7 +83,7 @@ https://vigorous-heyrovsky-1trvv0ikx9.projects.oryapis.com/oauth2/auth?client_id By default the client will use the local docker environment and expects a local instance of the FAPI api to be running on localhost:8020. Testing against the deployed API can be achieved by setting the `AUTHENTICATION_API` and `RESOURCE_API` environment variables, and optionally the FAPI_API environment variable. ```bash -FAPI_API=https://perseus-demo-fapi.ib1.org AUTHENTICATION_API="https://perseus-demo-authentication.ib1.org" RESOURCE_API=https://perseus-demo-energy.ib1.org python -W ignore client.py auth +FAPI_API=https://perseus-demo-authentication.ib1.org AUTHENTICATION_API="https://perseus-demo-authentication.ib1.org" RESOURCE_API=https://perseus-demo-energy.ib1.org python -W ignore client.py auth ``` Opening the redirect url will present you with the default Ory Hydra log in/ sign up screen, followed by a consent screen: