Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug fixes, improved introspection endpoint #22

Merged
merged 3 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
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
13 changes: 11 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 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: 0 additions & 1 deletion resource/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,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:
Expand Down
Loading