Skip to content

Commit

Permalink
Removes reduntant code, fixes introspection endpoint, fixes local dev…
Browse files Browse the repository at this point in the history
… environment
  • Loading branch information
kipparker committed Apr 19, 2024
1 parent b09a3cb commit 5cc5473
Show file tree
Hide file tree
Showing 11 changed files with 115 additions and 80 deletions.
10 changes: 7 additions & 3 deletions authentication/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}")

Expand Down
2 changes: 1 addition & 1 deletion authentication/api/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions authentication/api/examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 61 additions & 21 deletions authentication/api/main.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -24,14 +32,42 @@
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"}


@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()],
Expand Down Expand Up @@ -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"],
Expand Down
14 changes: 13 additions & 1 deletion authentication/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
}
}
Expand Down
8 changes: 6 additions & 2 deletions authentication/api/par.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
44 changes: 0 additions & 44 deletions client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 5 additions & 4 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
14 changes: 12 additions & 2 deletions resource/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,30 @@ 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()


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}")
Expand All @@ -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()


Expand Down
6 changes: 4 additions & 2 deletions resource/api/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
1 change: 1 addition & 0 deletions resource/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down

0 comments on commit 5cc5473

Please sign in to comment.