Add resource provided registry and plugin system
All checks were successful
/ build (push) Successful in 6s
/ test (push) Successful in 5s

This commit is contained in:
phil 2025-02-11 17:27:49 +01:00
parent e56be3c378
commit 64f6a90f22
10 changed files with 229 additions and 142 deletions

View file

@ -5,23 +5,23 @@ import logging
from authlib.oauth2.auth import OAuth2Token
from httpx import AsyncClient
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from fastapi import FastAPI, HTTPException, Depends, status
from fastapi import FastAPI, HTTPException, Depends, Request, status
from fastapi.middleware.cors import CORSMiddleware
# from starlette.middleware.sessions import SessionMiddleware
# from authlib.integrations.starlette_client.apps import StarletteOAuth2App
# from authlib.oauth2.rfc6749 import OAuth2Token
from .auth.provider import Provider
from .auth.utils import (
from oidc_test.auth.provider import Provider
from oidc_test.auth.utils import (
get_token_or_none,
get_user_from_token,
UserWithRole,
)
from .auth_providers import providers
from .settings import settings
from .models import User
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, registry
logger = logging.getLogger("oidc-test")
@ -91,6 +91,128 @@ async def get_protected_by_foorole_or_barrole(
return {"msg": "Only users with foorole or barrole can see this"}
@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)],
token: Annotated[OAuth2Token | None, Depends(get_token_or_none)],
request: Request,
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,
access_token=token["access_token"] if token else None,
user=user,
)
# Internal resource (provided here)
if resource_name in registry.resource_providers:
resource_provider = registry.resource_providers[resource_name]
if resource_provider.scope_required is not None and user.has_scope(
resource_provider.scope_required
):
try:
return await resource_provider.process(user=user, resource_id=resource_id)
except ProcessError as err:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, f"Cannot process resource: {err}"
)
else:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
f"No scope {resource_provider.scope_required} in the access token "
+ "but it is required for accessing this resource",
)
else:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Unknown resource {resource_name}")
# return await get_resource_(resource_name, user, **request.query_params)
async def get_auth_provider_resource(
provider: Provider, resource_name: str, access_token: str | None, user: User
) -> ProcessResult:
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,
headers={
"Content-type": "application/json",
"Authorization": f"Bearer {access_token}",
},
)
if resp.is_error:
raise HTTPException(resp.status_code, f"Cannot fetch resource: {resp.reason_phrase}")
# 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"}
)
else:
return ProcessResult(result=resp.json())
# async def get_resource_(resource_id: str, user: User, **kwargs) -> dict:
# """
# Resource processing: build an informative rely as a simple showcase
# """
# if resource_id == "petition":
# return await sign(user, kwargs["petition_id"])
# provider = providers[user.auth_provider_id]
# try:
# pname = provider.name
# except KeyError:
# pname = "?"
# resp = {
# "hello": f"Hi {user.name} from an OAuth resource provider",
# "comment": f"I received a request for '{resource_id}' "
# + f"with an access token signed by {pname}",
# }
# # For the demo, resource resource_id matches a scope get:resource_id,
# # but this has to be refined for production
# required_scope = f"get:{resource_id}"
# # Check if the required scope is in the scopes allowed in userinfo
# try:
# if user.has_scope(required_scope):
# await process(user, resource_id, resp)
# else:
# ## For the showcase, giving a explanation.
# ## Alternatively, raise HTTP_401_UNAUTHORIZED
# raise HTTPException(
# status.HTTP_401_UNAUTHORIZED,
# f"No scope {required_scope} in the access token "
# + "but it is required for accessing this resource",
# )
# except ExpiredSignatureError:
# raise HTTPException(status.HTTP_401_UNAUTHORIZED, "The token's signature has expired")
# except InvalidTokenError:
# raise HTTPException(status.HTTP_401_UNAUTHORIZED, "The token is invalid")
# return resp
# async def process(user, resource_id, resp):
# """
# Too simple to be serious.
# It's a good fit for a plugin architecture for production
# """
# if resource_id == "time":
# resp["time"] = datetime.now().strftime("%c")
# elif resource_id == "bs":
# async with AsyncClient() as client:
# bs = await client.get("https://corporatebs-generator.sameerkumar.website/")
# resp["bs"] = bs.json().get("phrase", "Sorry, i am out of BS today.")
# else:
# raise HTTPException(
# status.HTTP_401_UNAUTHORIZED, f"I don't known how to give '{resource_id}'."
# )
# @resource_server.get("/introspect")
# async def get_introspect(
# request: Request,
@ -114,99 +236,6 @@ async def get_protected_by_foorole_or_barrole(
# else:
# raise HTTPException(status_code=response.status_code, detail=response.text)
@resource_server.get("/{id}")
async def get_resource(
id: str,
user: Annotated[User, Depends(get_user_from_token)],
token: Annotated[OAuth2Token | None, Depends(get_token_or_none)],
) -> dict | list:
"""Generic path for testing a resource provided by a provider"""
provider = providers[user.auth_provider_id]
if id in [r.id for r in provider.resources]:
return await get_external_resource(
provider=provider,
id=id,
access_token=token["access_token"] if token else None,
user=user,
)
return await get_resource_(id, user)
async def get_external_resource(
provider: Provider, id: str, access_token: str | None, user: User
) -> dict | list:
resource = [r for r in provider.resources if r.id == id][0]
async with AsyncClient() as client:
resp = await client.get(
url=provider.url + resource.url,
headers={
"Content-type": "application/json",
"Authorization": f"Bearer {access_token}",
},
)
if resp.is_error:
raise HTTPException(resp.status_code, f"Cannot fetch resource: {resp.reason_phrase}")
resp_length = len(resp.text)
if resp_length > 1024:
return {"msg": f"The resource is too long ({resp_length} bytes) to show here"}
else:
return resp.json()
async def get_resource_(resource_id: str, user: User) -> dict:
"""
Resource processing: build an informative rely as a simple showcase
"""
provider = providers[user.auth_provider_id]
try:
pname = provider.name
except KeyError:
pname = "?"
resp = {
"hello": f"Hi {user.name} from an OAuth resource provider",
"comment": f"I received a request for '{resource_id}' "
+ f"with an access token signed by {pname}",
}
# For the demo, resource resource_id matches a scope get:resource_id,
# but this has to be refined for production
required_scope = f"get:{resource_id}"
# Check if the required scope is in the scopes allowed in userinfo
try:
if user.has_scope(required_scope):
await process(user, resource_id, resp)
else:
## For the showcase, giving a explanation.
## Alternatively, raise HTTP_401_UNAUTHORIZED
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
f"No scope {required_scope} in the access token "
+ "but it is required for accessing this resource",
)
except ExpiredSignatureError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "The token's signature has expired")
except InvalidTokenError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "The token is invalid")
return resp
async def process(user, resource_id, resp):
"""
Too simple to be serious.
It's a good fit for a plugin architecture for production
"""
if resource_id == "time":
resp["time"] = datetime.now().strftime("%c")
elif resource_id == "bs":
async with AsyncClient() as client:
bs = await client.get("https://corporatebs-generator.sameerkumar.website/")
resp["bs"] = bs.json().get("phrase", "Sorry, i am out of BS today.")
else:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, f"I don't known how to give '{resource_id}'."
)
# assert user.oidc_provider is not None
### Get some info (TODO: refactor)
# if (auth_provider_id := user.oidc_provider.name) is None: