From f9108347369a24dd2998323d140c5fa4dcab4a88 Mon Sep 17 00:00:00 2001
From: phil
Date: Wed, 29 Jan 2025 14:03:33 +0100
Subject: [PATCH 01/70] Fetch provider info at boot time: get public key from
there instead of in settings
---
src/oidc_test/auth_utils.py | 63 ++++++++++++++++++++++++-------------
src/oidc_test/main.py | 14 +++++++--
src/oidc_test/settings.py | 13 +++++---
3 files changed, 61 insertions(+), 29 deletions(-)
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py
index 2b3d0fd..d64b6cf 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth_utils.py
@@ -6,7 +6,8 @@ from fastapi import HTTPException, Request, Depends, status
from fastapi.security import OAuth2PasswordBearer
from authlib.oauth2.rfc6749 import OAuth2Token
from authlib.integrations.starlette_client import OAuth, OAuthError, StarletteOAuth2App
-from jwt import decode
+from jwt import ExpiredSignatureError, InvalidKeyError, decode
+from httpx import AsyncClient
# from authlib.oauth1.auth import OAuthToken
# from authlib.oauth2.auth import OAuth2Token
@@ -41,23 +42,35 @@ def update_token(*args, **kwargs):
authlib_oauth = OAuth(cache=None, fetch_token=fetch_token, update_token=update_token)
+def init_providers():
# Add oidc providers to authlib from the settings
-for id, provider in oidc_providers_settings.items():
- authlib_oauth.register(
- name=id,
- server_metadata_url=provider.openid_configuration,
- client_kwargs={
- "scope": "openid email offline_access profile",
- },
- client_id=provider.client_id,
- client_secret=provider.client_secret,
- api_base_url=provider.url,
- # For PKCE (not implemented yet):
- # code_challenge_method="S256",
- # fetch_token=fetch_token,
- # update_token=update_token,
- # client_id="some-client-id", # if enabled, authlib will also check that the access token belongs to this client id (audience)
- )
+ for id, provider in oidc_providers_settings.items():
+ authlib_oauth.register(
+ name=id,
+ server_metadata_url=provider.openid_configuration,
+ client_kwargs={
+ "scope": "openid email offline_access profile",
+ },
+ client_id=provider.client_id,
+ client_secret=provider.client_secret,
+ api_base_url=provider.url,
+ # For PKCE (not implemented yet):
+ # code_challenge_method="S256",
+ # fetch_token=fetch_token,
+ # update_token=update_token,
+ # client_id="some-client-id", # if enabled, authlib will also check that the access token belongs to this client id (audience)
+ )
+
+init_providers()
+
+async def get_providers_info():
+ # Get the public key:
+ async with AsyncClient() as client:
+ for provider_settings in oidc_providers_settings.values():
+ if provider_settings.info_url:
+ provider_info = await client.get(provider_settings.url)
+ provider_settings.info = provider_info.json()
+
def get_oidc_provider_or_none(request: Request) -> StarletteOAuth2App | None:
"""Return the oidc_provider from a request object, from the session.
@@ -156,20 +169,26 @@ def get_token_info(token: dict) -> dict:
return token_info
-async def get_resource_user(
+async def get_user_from_token(
token: Annotated[str, Depends(oauth2_scheme)],
request: Request,
) -> User:
- # TODO: decode token (ah!)
- # See https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/#hash-and-verify-the-passwords
if (auth_provider_id := request.headers.get("auth_provider")) is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Request headers must have a 'auth_provider' field")
- if (auth_provider := oidc_providers_settings.get(auth_provider_id)) is None:
+ if (auth_provider_settings := oidc_providers_settings.get(auth_provider_id)) is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Unknown auth provider '{auth_provider_id}'")
- if (key := auth_provider.get_key()) is None:
+ oidc_provider: StarletteOAuth2App = getattr(authlib_oauth, auth_provider_id)
+ await oidc_provider.load_server_metadata()
+ if (key := auth_provider_settings.get_public_key()) is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Key for provider '{auth_provider_id}' unknown")
try:
payload = decode(token, key=key, algorithms=["RS256"], audience="oidc-test")
+ except ExpiredSignatureError as err:
+ logger.info(f"Expired signature: {err}")
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Expired signature (refresh not implemented yet)")
+ except InvalidKeyError as err:
+ logger.info(f"Invalid key: {err}")
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid auth provider key")
except Exception as err:
logger.info("Cannot decode token, see below")
logger.exception(err)
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 351cc2f..6942759 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -7,6 +7,7 @@ from pathlib import Path
from datetime import datetime
import logging
from urllib.parse import urlencode
+from contextlib import asynccontextmanager
from httpx import HTTPError
from fastapi import Depends, FastAPI, HTTPException, Request, status
@@ -32,10 +33,11 @@ from .auth_utils import (
hasrole,
get_current_user_or_none,
get_current_user,
- get_resource_user,
+ get_user_from_token,
authlib_oauth,
get_token,
oidc_providers_settings,
+ get_providers_info,
)
from .auth_misc import pretty_details
from .database import db
@@ -51,10 +53,18 @@ origins = [
"https://philo.ydns.eu/",
]
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ await get_providers_info()
+ yield
+
+
app = FastAPI(
title="OIDC auth test",
+ lifespan=lifespan
)
+
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
@@ -278,7 +288,7 @@ async def get_resource_(
# user: Annotated[User, Depends(get_current_user)],
# oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
# token: Annotated[OAuth2Token, Depends(get_token)],
- user: Annotated[User, Depends(get_resource_user)],
+ 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))
diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py
index c511f86..00c3f23 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -36,7 +36,9 @@ class OIDCProvider(BaseModel):
hint: str = "No hint"
resources: list[Resource] = []
account_url_template: str | None = None
- key: str | None = None
+ 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
+ public_key: str | None = None
@computed_field
@property
@@ -64,13 +66,14 @@ class OIDCProvider(BaseModel):
else:
return None
- def get_key(self) -> str | None:
- """Return the public key formatted for """
- if self.key is None:
+ def get_public_key(self) -> str | None:
+ """Return the public key formatted for decoding token"""
+ public_key = self.public_key or (self.info is not None and self.info["public_key"])
+ if public_key is None:
return None
return f"""
-----BEGIN PUBLIC KEY-----
- {self.key}
+ {public_key}
-----END PUBLIC KEY-----
"""
From b3e19b3e4083cf9531fa9b52dc32501f441feab6 Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 30 Jan 2025 20:40:04 +0100
Subject: [PATCH 02/70] Resource server: read the required scope in access
token
---
src/oidc_test/auth_utils.py | 58 +++++++++++++++++-------
src/oidc_test/main.py | 9 ++--
src/oidc_test/resource_server.py | 75 +++++++++++++++++++++++++++-----
src/oidc_test/settings.py | 24 +++++++---
4 files changed, 129 insertions(+), 37 deletions(-)
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py
index d64b6cf..2fcfc76 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth_utils.py
@@ -1,4 +1,4 @@
-from typing import Union, Annotated, Tuple
+from typing import Union, Annotated
from functools import wraps
import logging
@@ -18,10 +18,13 @@ from .settings import settings, OIDCProvider
logger = logging.getLogger(__name__)
-oidc_providers_settings: dict[str, OIDCProvider] = dict([(provider.id, provider) for provider in settings.oidc.providers])
+oidc_providers_settings: dict[str, OIDCProvider] = dict(
+ [(provider.id, provider) for provider in settings.oidc.providers]
+)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
+
def fetch_token(name, request):
breakpoint()
...
@@ -43,7 +46,7 @@ authlib_oauth = OAuth(cache=None, fetch_token=fetch_token, update_token=update_t
def init_providers():
-# Add oidc providers to authlib from the settings
+ # Add oidc providers to authlib from the settings
for id, provider in oidc_providers_settings.items():
authlib_oauth.register(
name=id,
@@ -61,8 +64,10 @@ def init_providers():
# client_id="some-client-id", # if enabled, authlib will also check that the access token belongs to this client id (audience)
)
+
init_providers()
+
async def get_providers_info():
# Get the public key:
async with AsyncClient() as client:
@@ -174,18 +179,35 @@ async def get_user_from_token(
request: Request,
) -> User:
if (auth_provider_id := request.headers.get("auth_provider")) is None:
- raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Request headers must have a 'auth_provider' field")
- if (auth_provider_settings := oidc_providers_settings.get(auth_provider_id)) is None:
- raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Unknown auth provider '{auth_provider_id}'")
- oidc_provider: StarletteOAuth2App = getattr(authlib_oauth, auth_provider_id)
- await oidc_provider.load_server_metadata()
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED,
+ "Request headers must have a 'auth_provider' field",
+ )
+ if (
+ auth_provider_settings := oidc_providers_settings.get(auth_provider_id)
+ ) is None:
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED, f"Unknown auth provider '{auth_provider_id}'"
+ )
if (key := auth_provider_settings.get_public_key()) is None:
- raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Key for provider '{auth_provider_id}' unknown")
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED,
+ f"Key for provider '{auth_provider_id}' unknown",
+ )
try:
- payload = decode(token, key=key, algorithms=["RS256"], audience="oidc-test")
+ payload = decode(
+ token,
+ key=key,
+ algorithms=["RS256"],
+ audience="oidc-test",
+ options={"verify_signature": not settings.insecure.skip_verify_signature},
+ )
except ExpiredSignatureError as err:
logger.info(f"Expired signature: {err}")
- raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Expired signature (refresh not implemented yet)")
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED,
+ "Expired signature (refresh not implemented yet)",
+ )
except InvalidKeyError as err:
logger.info(f"Invalid key: {err}")
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid auth provider key")
@@ -193,16 +215,20 @@ async def get_user_from_token(
logger.info("Cannot decode token, see below")
logger.exception(err)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Cannot decode token")
- if (user_id := payload.get('sub')) is None:
- raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong token: 'sub' (user id) not found")
+ if (user_id := payload.get("sub")) is None:
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED, "Wrong token: 'sub' (user id) not found"
+ )
try:
user = await db.get_user(user_id)
except UserNotInDB:
- logger.info(f"User {user_id} not found in DB, creating it (real apps can behave differently")
+ logger.info(
+ f"User {user_id} not found in DB, creating it (real apps can behave differently"
+ )
user = await db.add_user(
- sub=payload['sub'],
+ sub=payload["sub"],
user_info=payload,
oidc_provider=getattr(authlib_oauth, auth_provider_id),
- user_info_from_endpoint={}
+ user_info_from_endpoint={},
)
return user
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 6942759..f6ce405 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -53,16 +53,14 @@ origins = [
"https://philo.ydns.eu/",
]
+
@asynccontextmanager
async def lifespan(app: FastAPI):
await get_providers_info()
yield
-app = FastAPI(
- title="OIDC auth test",
- lifespan=lifespan
-)
+app = FastAPI(title="OIDC auth test", lifespan=lifespan)
app.add_middleware(
@@ -284,7 +282,6 @@ async def non_compliant_logout(
@app.get("/resource/{id}")
async def get_resource_(
id: str,
- request: Request,
# user: Annotated[User, Depends(get_current_user)],
# oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
# token: Annotated[OAuth2Token, Depends(get_token)],
@@ -294,7 +291,7 @@ async def get_resource_(
return JSONResponse(await get_resource(id, user))
-# Routes for test
+# Routes for RBAC based tests
@app.get("/public")
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index a9dfe3a..0186064 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -1,21 +1,76 @@
from datetime import datetime
+import logging
+
from httpx import AsyncClient
+from fastapi import HTTPException, status
+from jwt import ExpiredSignatureError, InvalidKeyError, decode
from .models import User
+from .auth_utils import oidc_providers_settings
+from .settings import settings
+
+logger = logging.getLogger(__name__)
+
async def get_resource(id: str, user: User) -> dict:
pname = getattr(user.oidc_provider, "name", "?")
resp = {
- "hello" : f"Hi {user.name} from an OAuth resource provider.",
- "comment": f"I received a request for '{id}' with an access token signed by {pname}."
+ "hello": f"Hi {user.name} from an OAuth resource provider.",
+ "comment": f"I received a request for '{id}' with an access token signed by {pname}.",
}
- if id == "time":
- resp["time"] = datetime.now().strftime("%c")
- elif 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.")
+ scope = f"get:{id}"
+ user_scopes = user.userinfo["scope"].split(" ")
+ if scope in user_scopes:
+ if id == "time":
+ resp["time"] = datetime.now().strftime("%c")
+ elif 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:
+ resp["sorry"] = f"I don't known how to give '{id}' but i know corporate bs."
else:
- resp['sorry'] = f"I don't known how to give '{id}' but i know corporate bs."
-
+ resp["sorry"] = (
+ f"I don't serve the ressource {id} to you because"
+ "there is no scope {scope} in the access token,"
+ )
return resp
+
+ # assert user.oidc_provider is not None
+ ### Get some info (TODO: refactor)
+ # if (auth_provider_id := user.oidc_provider.name) is None:
+ # raise HTTPException(
+ # status.HTTP_401_UNAUTHORIZED,
+ # "Request headers must have a 'auth_provider' field",
+ # )
+ # if (
+ # auth_provider_settings := oidc_providers_settings.get(auth_provider_id)
+ # ) is None:
+ # raise HTTPException(
+ # status.HTTP_401_UNAUTHORIZED, f"Unknown auth provider '{auth_provider_id}'"
+ # )
+ # if (key := auth_provider_settings.get_public_key()) is None:
+ # raise HTTPException(
+ # status.HTTP_401_UNAUTHORIZED,
+ # f"Key for provider '{auth_provider_id}' unknown",
+ # )
+ # logger.warn(f"refresh with scope {scope}")
+ # breakpoint()
+ # refreshed_auth_info = await user.oidc_provider.fetch_access_token(scope=scope)
+ ### Decode the new token
+ # try:
+ # payload = decode(
+ # refreshed_auth_info["access_token"],
+ # key=key,
+ # algorithms=["RS256"],
+ # audience="account",
+ # options={"verify_signature": not settings.insecure.skip_verify_signature},
+ # )
+ # except ExpiredSignatureError as err:
+ # logger.info(f"Expired signature: {err}")
+ # raise HTTPException(
+ # status.HTTP_401_UNAUTHORIZED,
+ # "Expired signature (refresh not implemented yet)",
+ # )
diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py
index 00c3f23..399fbac 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -36,8 +36,12 @@ class OIDCProvider(BaseModel):
hint: str = "No hint"
resources: list[Resource] = []
account_url_template: str | None = None
- 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_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
+ )
public_key: str | None = None
@computed_field
@@ -68,7 +72,9 @@ class OIDCProvider(BaseModel):
def get_public_key(self) -> str | None:
"""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:
return None
return f"""
@@ -77,6 +83,7 @@ class OIDCProvider(BaseModel):
-----END PUBLIC KEY-----
"""
+
class ResourceProvider(BaseModel):
id: str
name: str
@@ -90,15 +97,22 @@ class OIDCSettings(BaseModel):
swagger_provider: str = ""
+class Insecure(BaseModel):
+ """Warning: changing these defaults are only suitable for debugging"""
+
+ skip_verify_signature: bool = False
+
+
class Settings(BaseSettings):
"""Settings wil be read from an .env file"""
+ model_config = SettingsConfigDict(env_nested_delimiter="__")
+
oidc: OIDCSettings = OIDCSettings()
resource_providers: list[ResourceProvider] = []
secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16))
log: bool = False
-
- model_config = SettingsConfigDict(env_nested_delimiter="__")
+ insecure: Insecure = Insecure()
@classmethod
def settings_customise_sources(
From 815a4503df613e5f8f7819522a8064159665f813 Mon Sep 17 00:00:00 2001
From: phil
Date: Fri, 31 Jan 2025 00:12:50 +0100
Subject: [PATCH 03/70] Add cors origins setting
---
src/oidc_test/main.py | 8 +-------
src/oidc_test/settings.py | 1 +
2 files changed, 2 insertions(+), 7 deletions(-)
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index f6ce405..e02a627 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -48,12 +48,6 @@ logger = logging.getLogger("uvicorn.error")
templates = Jinja2Templates(Path(__file__).parent / "templates")
-origins = [
- "https://tiptop:3002",
- "https://philo.ydns.eu/",
-]
-
-
@asynccontextmanager
async def lifespan(app: FastAPI):
await get_providers_info()
@@ -65,7 +59,7 @@ app = FastAPI(title="OIDC auth test", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
- allow_origins=origins,
+ allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py
index 399fbac..329b9c0 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -113,6 +113,7 @@ class Settings(BaseSettings):
secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16))
log: bool = False
insecure: Insecure = Insecure()
+ cors_origins: list[str] = []
@classmethod
def settings_customise_sources(
From f7ea132b7cf29ec3c7eccdeb390237d3ea536384 Mon Sep 17 00:00:00 2001
From: phil
Date: Fri, 31 Jan 2025 11:43:11 +0100
Subject: [PATCH 04/70] Fix resource server error message
---
src/oidc_test/resource_server.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index 0186064..34a77cc 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -33,8 +33,7 @@ async def get_resource(id: str, user: User) -> dict:
resp["sorry"] = f"I don't known how to give '{id}' but i know corporate bs."
else:
resp["sorry"] = (
- f"I don't serve the ressource {id} to you because"
- "there is no scope {scope} in the access token,"
+ f"I don't serve the ressource {id} to you because there is no scope {scope} in the access token,"
)
return resp
From 17bf34a8a10ac0c76eeb598cfb9713750c2b8eb8 Mon Sep 17 00:00:00 2001
From: phil
Date: Sat, 1 Feb 2025 02:01:53 +0100
Subject: [PATCH 05/70] Fix error handling in resource server
---
src/oidc_test/database.py | 1 +
src/oidc_test/main.py | 1 -
src/oidc_test/resource_server.py | 135 ++++++++++++++++++-------------
3 files changed, 78 insertions(+), 59 deletions(-)
diff --git a/src/oidc_test/database.py b/src/oidc_test/database.py
index 9d72081..0252d28 100644
--- a/src/oidc_test/database.py
+++ b/src/oidc_test/database.py
@@ -13,6 +13,7 @@ logger = logging.getLogger(__name__)
class UserNotInDB(Exception):
pass
+
class Database:
users: dict[str, User] = {}
tokens: dict[str, OAuth2Token] = {}
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index e02a627..739dd1b 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -56,7 +56,6 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="OIDC auth test", lifespan=lifespan)
-
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index 34a77cc..67f87dd 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -2,74 +2,93 @@ from datetime import datetime
import logging
from httpx import AsyncClient
-from fastapi import HTTPException, status
-from jwt import ExpiredSignatureError, InvalidKeyError, decode
from .models import User
-from .auth_utils import oidc_providers_settings
-from .settings import settings
logger = logging.getLogger(__name__)
-async def get_resource(id: str, user: User) -> dict:
+async def get_resource(resource_id: str, user: User) -> dict:
+ """
+ Resource processing: build an informative rely as a simple showcase
+ """
pname = getattr(user.oidc_provider, "name", "?")
resp = {
- "hello": f"Hi {user.name} from an OAuth resource provider.",
- "comment": f"I received a request for '{id}' with an access token signed by {pname}.",
+ "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}",
}
- scope = f"get:{id}"
- user_scopes = user.userinfo["scope"].split(" ")
- if scope in user_scopes:
- if id == "time":
- resp["time"] = datetime.now().strftime("%c")
- elif 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.")
+ # 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
+ if "required_scope" in user.userinfo:
+ user_scopes = user.userinfo["required_scope"].split(" ")
+ if required_scope in user_scopes:
+ await process(user, required_scope, resp)
else:
- resp["sorry"] = f"I don't known how to give '{id}' but i know corporate bs."
+ ## For the showcase, giving a explanation.
+ ## Alternatively, raise HTTP_401_UNAUTHORIZED
+ resp["sorry"] = (
+ f"No scope {required_scope} in the access token "
+ + "but it is required for accessing this resource."
+ )
else:
- resp["sorry"] = (
- f"I don't serve the ressource {id} to you because there is no scope {scope} in the access token,"
- )
+ resp["sorry"] = "There is no scope in id token"
return resp
- # assert user.oidc_provider is not None
- ### Get some info (TODO: refactor)
- # if (auth_provider_id := user.oidc_provider.name) is None:
- # raise HTTPException(
- # status.HTTP_401_UNAUTHORIZED,
- # "Request headers must have a 'auth_provider' field",
- # )
- # if (
- # auth_provider_settings := oidc_providers_settings.get(auth_provider_id)
- # ) is None:
- # raise HTTPException(
- # status.HTTP_401_UNAUTHORIZED, f"Unknown auth provider '{auth_provider_id}'"
- # )
- # if (key := auth_provider_settings.get_public_key()) is None:
- # raise HTTPException(
- # status.HTTP_401_UNAUTHORIZED,
- # f"Key for provider '{auth_provider_id}' unknown",
- # )
- # logger.warn(f"refresh with scope {scope}")
- # breakpoint()
- # refreshed_auth_info = await user.oidc_provider.fetch_access_token(scope=scope)
- ### Decode the new token
- # try:
- # payload = decode(
- # refreshed_auth_info["access_token"],
- # key=key,
- # algorithms=["RS256"],
- # audience="account",
- # options={"verify_signature": not settings.insecure.skip_verify_signature},
- # )
- # except ExpiredSignatureError as err:
- # logger.info(f"Expired signature: {err}")
- # raise HTTPException(
- # status.HTTP_401_UNAUTHORIZED,
- # "Expired signature (refresh not implemented yet)",
- # )
+
+async def process(user, resource_id, resp):
+ """
+ Too simple to be serious.
+ It's a good fit for a plugin architecture for production
+ """
+ assert user is not None
+ 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:
+ resp["sorry"] = (
+ f"I don't known how to give '{resource_id}' but i know corporate bs."
+ )
+
+
+# assert user.oidc_provider is not None
+### Get some info (TODO: refactor)
+# if (auth_provider_id := user.oidc_provider.name) is None:
+# raise HTTPException(
+# status.HTTP_401_UNAUTHORIZED,
+# "Request headers must have a 'auth_provider' field",
+# )
+# if (
+# auth_provider_settings := oidc_providers_settings.get(auth_provider_id)
+# ) is None:
+# raise HTTPException(
+# status.HTTP_401_UNAUTHORIZED, f"Unknown auth provider '{auth_provider_id}'"
+# )
+# if (key := auth_provider_settings.get_public_key()) is None:
+# raise HTTPException(
+# status.HTTP_401_UNAUTHORIZED,
+# f"Key for provider '{auth_provider_id}' unknown",
+# )
+# logger.warn(f"refresh with scope {scope}")
+# breakpoint()
+# refreshed_auth_info = await user.oidc_provider.fetch_access_token(scope=scope)
+### Decode the new token
+# try:
+# payload = decode(
+# refreshed_auth_info["access_token"],
+# key=key,
+# algorithms=["RS256"],
+# audience="account",
+# options={"verify_signature": not settings.insecure.skip_verify_signature},
+# )
+# except ExpiredSignatureError as err:
+# logger.info(f"Expired signature: {err}")
+# raise HTTPException(
+# status.HTTP_401_UNAUTHORIZED,
+# "Expired signature (refresh not implemented yet)",
+# )
From e90a1cc920d4296589f88fa8e5a0556660339fb3 Mon Sep 17 00:00:00 2001
From: phil
Date: Sat, 1 Feb 2025 02:16:40 +0100
Subject: [PATCH 06/70] Update README
---
README.md | 24 ++++++++++++++++++++++--
1 file changed, 22 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 0dc11be..9e00474 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,12 @@ as a template for integration in other FastAPI/SQLModel applications.
Feedback welcome.
+## Resource server
+
+It also functions as a resource server in a OAuth architecture.
+See a sibling test project, a web based OIDC/OAuth:
+[oidc-vue-test](https://code.philo.ydns.eu/philorg/oidc-vue-test).
+
## RBAC
The application is also a playground for RBAC (Role Based Access control)
@@ -45,7 +51,7 @@ given by the OIDC providers.
For example:
-```text
+```yaml
oidc:
secret_key: "ASecretNoOneKnows"
show_session_details: yes
@@ -60,6 +66,7 @@ oidc:
- id: keycloak
name: Keycloak at somewhere
url: "https://"
+ account_url_template: "/account"
client_id: ""
client_secret: "client_secret_generated_by_keycloak"
hint: "User: foo, password: foofoo"
@@ -67,14 +74,27 @@ oidc:
- id: codeberg
name: Codeberg
url: "https://codeberg.org"
+ account_url_template: "/user/settings"
client_id: ""
client_secret: "client_secret_generated_by_codeberg"
+ resources:
+ - name: List of repos
+ id: repos
+ url: /api/v1/user/repos
+ - name: List of OAuth2 applications
+ id: oauth2_applications
+ url: /api/v1/user/applications/oauth2
+
+cors_origins:
+ - https://some.client
+ - https://localhost:8000
```
The application reads the `OIDC_TEST_SETTINGS_FILE` environment variable
to determine the location of this file at startup.
-For example, to run on port 8000 in a container, with the setting file in the current working directory:
+For example, to run on port 8000 in a container,
+with the setting file in the current working directory:
```sh
podman run -p 8000:80 --env OIDC_TEST_CONFIG_FILE=/app/settings.yaml --mount type=bind,source=settings.yaml,destination=/app/settings.yaml code.philo.ydns.eu/philorg/oidc-fastapi-test:latest
From e9bc6c671ae23e0eb4f74f8f207eda6d02625f28 Mon Sep 17 00:00:00 2001
From: phil
Date: Sat, 1 Feb 2025 11:30:45 +0100
Subject: [PATCH 07/70] Cosmetic
---
src/oidc_test/static/styles.css | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/oidc_test/static/styles.css b/src/oidc_test/static/styles.css
index 6065a91..a4a0178 100644
--- a/src/oidc_test/static/styles.css
+++ b/src/oidc_test/static/styles.css
@@ -2,11 +2,15 @@ body {
font-family: Arial, Helvetica, sans-serif;
background-color: floralwhite;
margin: 0;
+ font-family: system-ui;
}
h1 {
text-align: center;
background-color: #f7c7867d;
margin: 0 0 0.2em 0;
+ box-shadow: 0px 0.2em 0.2em #f7c7867d;
+ text-shadow: 0 0 2px #00000080;
+ font-weight: 200;
}
p {
margin: 0.2em;
@@ -146,7 +150,6 @@ hr {
max-width: 13em;
}
.providers .error {
- color: darkred;
padding: 3px 6px;
text-align: center;
font-weight: bold;
From 8b8bbcd7a0e0b49830b4e7da3ece323435060f48 Mon Sep 17 00:00:00 2001
From: phil
Date: Sat, 1 Feb 2025 18:51:17 +0100
Subject: [PATCH 08/70] Fix resource server error with scope
---
src/oidc_test/auth_utils.py | 2 ++
src/oidc_test/database.py | 2 ++
src/oidc_test/models.py | 7 +++++++
src/oidc_test/resource_server.py | 24 +++++++++---------------
4 files changed, 20 insertions(+), 15 deletions(-)
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py
index 2fcfc76..ed3350c 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth_utils.py
@@ -221,6 +221,7 @@ async def get_user_from_token(
)
try:
user = await db.get_user(user_id)
+ user.access_token = payload
except UserNotInDB:
logger.info(
f"User {user_id} not found in DB, creating it (real apps can behave differently"
@@ -230,5 +231,6 @@ async def get_user_from_token(
user_info=payload,
oidc_provider=getattr(authlib_oauth, auth_provider_id),
user_info_from_endpoint={},
+ access_token=payload,
)
return user
diff --git a/src/oidc_test/database.py b/src/oidc_test/database.py
index 0252d28..5dec7fc 100644
--- a/src/oidc_test/database.py
+++ b/src/oidc_test/database.py
@@ -26,8 +26,10 @@ class Database:
user_info: dict,
oidc_provider: StarletteOAuth2App,
user_info_from_endpoint: dict,
+ access_token: dict,
) -> User:
user = User.from_auth(userinfo=user_info, oidc_provider=oidc_provider)
+ user.access_token = access_token
try:
raw_roles = user_info_from_endpoint["resource_access"][
oidc_provider.client_id
diff --git a/src/oidc_test/models.py b/src/oidc_test/models.py
index b6a267e..542a9c4 100644
--- a/src/oidc_test/models.py
+++ b/src/oidc_test/models.py
@@ -32,6 +32,7 @@ class User(UserBase):
also the key for the database 'table'""",
)
userinfo: dict = {}
+ access_token: dict = {}
oidc_provider: StarletteOAuth2App | None = None
@classmethod
@@ -49,3 +50,9 @@ class User(UserBase):
@cached_property
def roles_as_set(self) -> set[str]:
return set([role.name for role in self.roles])
+
+ def has_scope(self, scope: str) -> bool:
+ """Check if the scope is present in user info or access token"""
+ info_scopes = self.userinfo.get("scope", "").split(" ")
+ access_token_scopes = self.access_token.get("scope", "").split(" ")
+ return scope in set(info_scopes + access_token_scopes)
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index 67f87dd..ecaa597 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -22,19 +22,15 @@ async def get_resource(resource_id: str, user: User) -> dict:
# 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
- if "required_scope" in user.userinfo:
- user_scopes = user.userinfo["required_scope"].split(" ")
- if required_scope in user_scopes:
- await process(user, required_scope, resp)
- else:
- ## For the showcase, giving a explanation.
- ## Alternatively, raise HTTP_401_UNAUTHORIZED
- resp["sorry"] = (
- f"No scope {required_scope} in the access token "
- + "but it is required for accessing this resource."
- )
+ if user.has_scope(required_scope):
+ await process(user, resource_id, resp)
else:
- resp["sorry"] = "There is no scope in id token"
+ ## For the showcase, giving a explanation.
+ ## Alternatively, raise HTTP_401_UNAUTHORIZED
+ resp["sorry"] = (
+ f"No scope {required_scope} in the access token "
+ + "but it is required for accessing this resource."
+ )
return resp
@@ -51,9 +47,7 @@ async def process(user, resource_id, resp):
bs = await client.get("https://corporatebs-generator.sameerkumar.website/")
resp["bs"] = bs.json().get("phrase", "Sorry, i am out of BS today.")
else:
- resp["sorry"] = (
- f"I don't known how to give '{resource_id}' but i know corporate bs."
- )
+ resp["sorry"] = f"I don't known how to give '{resource_id}'."
# assert user.oidc_provider is not None
From e1dac777388ca2f6850f88d9afccb4d2f394811c Mon Sep 17 00:00:00 2001
From: phil
Date: Sun, 2 Feb 2025 15:54:44 +0100
Subject: [PATCH 09/70] Decode access token, refactor
---
src/oidc_test/auth_utils.py | 22 ++++++----------------
src/oidc_test/database.py | 11 +++++++++--
src/oidc_test/main.py | 15 ++++++++++++---
src/oidc_test/settings.py | 16 ++++++++++++++--
src/oidc_test/static/styles.css | 1 +
5 files changed, 42 insertions(+), 23 deletions(-)
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py
index ed3350c..33ca582 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth_utils.py
@@ -6,14 +6,14 @@ from fastapi import HTTPException, Request, Depends, status
from fastapi.security import OAuth2PasswordBearer
from authlib.oauth2.rfc6749 import OAuth2Token
from authlib.integrations.starlette_client import OAuth, OAuthError, StarletteOAuth2App
-from jwt import ExpiredSignatureError, InvalidKeyError, decode
+from jwt import ExpiredSignatureError, InvalidKeyError
from httpx import AsyncClient
# from authlib.oauth1.auth import OAuthToken
# from authlib.oauth2.auth import OAuth2Token
from .models import User
-from .database import db, UserNotInDB
+from .database import TokenNotInDb, db, UserNotInDB
from .settings import settings, OIDCProvider
logger = logging.getLogger(__name__)
@@ -126,9 +126,10 @@ async def get_current_user(request: Request) -> User:
async def get_token(request: Request) -> OAuth2Token:
"""Return the token from a request object, from the session.
It can be used in Depends()"""
- if (token := await db.get_token(request.session.get("token"))) is None:
+ try:
+ return await db.get_token(request.session["token"])
+ except (KeyError, TokenNotInDb):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No token")
- return token
async def get_current_user_or_none(request: Request) -> User | None:
@@ -189,19 +190,8 @@ async def get_user_from_token(
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, f"Unknown auth provider '{auth_provider_id}'"
)
- if (key := auth_provider_settings.get_public_key()) is None:
- raise HTTPException(
- status.HTTP_401_UNAUTHORIZED,
- f"Key for provider '{auth_provider_id}' unknown",
- )
try:
- payload = decode(
- token,
- key=key,
- algorithms=["RS256"],
- audience="oidc-test",
- options={"verify_signature": not settings.insecure.skip_verify_signature},
- )
+ payload = auth_provider_settings.decode(token)
except ExpiredSignatureError as err:
logger.info(f"Expired signature: {err}")
raise HTTPException(
diff --git a/src/oidc_test/database.py b/src/oidc_test/database.py
index 5dec7fc..b2cf1b9 100644
--- a/src/oidc_test/database.py
+++ b/src/oidc_test/database.py
@@ -14,6 +14,10 @@ class UserNotInDB(Exception):
pass
+class TokenNotInDb(Exception):
+ pass
+
+
class Database:
users: dict[str, User] = {}
tokens: dict[str, OAuth2Token] = {}
@@ -50,8 +54,11 @@ class Database:
async def add_token(self, token: OAuth2Token, user: User) -> None:
self.tokens[token["id_token"]] = token
- async def get_token(self, id_token: str) -> OAuth2Token | None:
- return self.tokens.get(id_token)
+ async def get_token(self, id_token: str) -> OAuth2Token:
+ try:
+ return self.tokens[id_token]
+ except KeyError:
+ raise TokenNotInDb
db = Database()
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 739dd1b..ef19245 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -187,11 +187,20 @@ async def auth(request: Request, oidc_provider_id: str) -> RedirectResponse:
# Build and remember the user in the session
request.session["user_sub"] = sub
# Store the user in the database
+ try:
+ oidc_provider_settings = oidc_providers_settings[oidc_provider_id]
+ access_token = oidc_provider_settings.decode(token["access_token"])
+ except Exception:
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED,
+ detail="Cannot decode token or verify its signature",
+ )
user = await db.add_user(
sub,
user_info=userinfo,
oidc_provider=oidc_provider,
user_info_from_endpoint=user_info_from_endpoint,
+ access_token=access_token,
)
# Add the id_token to the session
request.session["token"] = token["id_token"]
@@ -213,14 +222,14 @@ async def account(
oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
) -> RedirectResponse:
if (
- provider := oidc_providers_settings.get(
+ oidc_provider_settings := oidc_providers_settings.get(
request.session.get("oidc_provider_id", "")
)
) is None:
raise HTTPException(
- status.HTTP_406_NOT_ACCEPTABLE, detail="No oidc provider setting"
+ status.HTTP_406_NOT_ACCEPTABLE, detail="No oidc provider settings"
)
- return RedirectResponse(f"{provider.account_url}")
+ return RedirectResponse(f"{oidc_provider_settings.account_url}")
@app.get("/logout")
diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py
index 329b9c0..46d857d 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -4,6 +4,7 @@ import random
from typing import Type, Tuple
from pathlib import Path
+from jwt import decode
from pydantic import BaseModel, computed_field, AnyUrl
from pydantic_settings import (
BaseSettings,
@@ -43,6 +44,7 @@ class OIDCProvider(BaseModel):
None # Info fetched from info_url, eg. public key
)
public_key: str | None = None
+ signature_alg: str = "RS256"
@computed_field
@property
@@ -70,19 +72,29 @@ class OIDCProvider(BaseModel):
else:
return None
- def get_public_key(self) -> str | 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"]
)
if public_key is None:
- return None
+ raise AttributeError(f"Cannot get public key for {self.name}")
return f"""
-----BEGIN PUBLIC KEY-----
{public_key}
-----END PUBLIC KEY-----
"""
+ def decode(self, token: str) -> dict:
+ """Decode the token with signature check"""
+ return decode(
+ token,
+ self.get_public_key(),
+ algorithms=[self.signature_alg],
+ audience=["oidc-test", "oidc-test-web"],
+ options={"verify_signature": not settings.insecure.skip_verify_signature},
+ )
+
class ResourceProvider(BaseModel):
id: str
diff --git a/src/oidc_test/static/styles.css b/src/oidc_test/static/styles.css
index a4a0178..cc84736 100644
--- a/src/oidc_test/static/styles.css
+++ b/src/oidc_test/static/styles.css
@@ -104,6 +104,7 @@ hr {
.role {
padding: 3px 6px;
background-color: #44228840;
+ border-radius: 6px;
}
/* For home */
From dc181bd3a841fcb3b598877035b217c056e8d192 Mon Sep 17 00:00:00 2001
From: phil
Date: Mon, 3 Feb 2025 13:20:33 +0100
Subject: [PATCH 10/70] Store raw access token within user; get resource
---
src/oidc_test/auth_utils.py | 13 +++++-----
src/oidc_test/database.py | 2 +-
src/oidc_test/main.py | 8 ++++---
src/oidc_test/models.py | 16 ++++++++++---
src/oidc_test/static/styles.css | 24 +++++++++----------
src/oidc_test/static/utils.js | 21 ++++++++++++++++
src/oidc_test/templates/home.html | 40 +++++++++++++++++++++++++++----
7 files changed, 94 insertions(+), 30 deletions(-)
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py
index 33ca582..4c3b98c 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth_utils.py
@@ -25,8 +25,8 @@ oidc_providers_settings: dict[str, OIDCProvider] = dict(
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
-def fetch_token(name, request):
- breakpoint()
+async def fetch_token(name, request):
+ logger.warn("TODO: fetch_token")
...
# if name in oidc_providers:
# model = OAuth2Token
@@ -37,8 +37,8 @@ def fetch_token(name, request):
# return token.to_token()
-def update_token(*args, **kwargs):
- breakpoint()
+async def update_token(*args, **kwargs):
+ logger.warn("TODO: update_token")
...
@@ -211,7 +211,8 @@ async def get_user_from_token(
)
try:
user = await db.get_user(user_id)
- user.access_token = payload
+ if user.access_token != token:
+ user.access_token = token
except UserNotInDB:
logger.info(
f"User {user_id} not found in DB, creating it (real apps can behave differently"
@@ -221,6 +222,6 @@ async def get_user_from_token(
user_info=payload,
oidc_provider=getattr(authlib_oauth, auth_provider_id),
user_info_from_endpoint={},
- access_token=payload,
+ access_token=token,
)
return user
diff --git a/src/oidc_test/database.py b/src/oidc_test/database.py
index b2cf1b9..1b682ef 100644
--- a/src/oidc_test/database.py
+++ b/src/oidc_test/database.py
@@ -30,7 +30,7 @@ class Database:
user_info: dict,
oidc_provider: StarletteOAuth2App,
user_info_from_endpoint: dict,
- access_token: dict,
+ access_token: str,
) -> User:
user = User.from_auth(userinfo=user_info, oidc_provider=oidc_provider)
user.access_token = access_token
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index ef19245..3d95009 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -200,7 +200,7 @@ async def auth(request: Request, oidc_provider_id: str) -> RedirectResponse:
user_info=userinfo,
oidc_provider=oidc_provider,
user_info_from_endpoint=user_info_from_endpoint,
- access_token=access_token,
+ access_token=token["access_token"],
)
# Add the id_token to the session
request.session["token"] = token["id_token"]
@@ -229,7 +229,7 @@ async def account(
raise HTTPException(
status.HTTP_406_NOT_ACCEPTABLE, detail="No oidc provider settings"
)
- return RedirectResponse(f"{oidc_provider_settings.account_url}")
+ return RedirectResponse(f"{oidc_provider_settings.account_url_template}")
@app.get("/logout")
@@ -243,7 +243,9 @@ async def logout(
if (
provider_logout_uri := oidc_provider.server_metadata.get("end_session_endpoint")
) is None:
- logger.warn(f"Cannot find end_session_endpoint for provider {provider.name}")
+ logger.warn(
+ f"Cannot find end_session_endpoint for provider {oidc_provider.name}"
+ )
return RedirectResponse(request.url_for("non_compliant_logout"))
post_logout_uri = request.url_for("home")
if (token := await db.get_token(request.session.pop("token", None))) is None:
diff --git a/src/oidc_test/models.py b/src/oidc_test/models.py
index 542a9c4..db5d6ad 100644
--- a/src/oidc_test/models.py
+++ b/src/oidc_test/models.py
@@ -25,14 +25,14 @@ class UserBase(SQLModel, extra="ignore"):
class User(UserBase):
- model_config = ConfigDict(arbitrary_types_allowed=True)
+ model_config = ConfigDict(arbitrary_types_allowed=True) # type:ignore
sub: str = Field(
description="""subject id of the user given by the oidc provider,
also the key for the database 'table'""",
)
userinfo: dict = {}
- access_token: dict = {}
+ access_token: str | None = None
oidc_provider: StarletteOAuth2App | None = None
@classmethod
@@ -54,5 +54,15 @@ class User(UserBase):
def has_scope(self, scope: str) -> bool:
"""Check if the scope is present in user info or access token"""
info_scopes = self.userinfo.get("scope", "").split(" ")
- access_token_scopes = self.access_token.get("scope", "").split(" ")
+ access_token_scopes = self.access_token_parsed().get("scope", "").split(" ")
return scope in set(info_scopes + access_token_scopes)
+
+ def access_token_parsed(self):
+ assert self.access_token is not None
+ assert self.oidc_provider is not None
+ assert self.oidc_provider.name is not None
+ from .auth_utils import oidc_providers_settings
+
+ return oidc_providers_settings[self.oidc_provider.name].decode(
+ self.access_token
+ )
diff --git a/src/oidc_test/static/styles.css b/src/oidc_test/static/styles.css
index cc84736..4552ca0 100644
--- a/src/oidc_test/static/styles.css
+++ b/src/oidc_test/static/styles.css
@@ -3,9 +3,9 @@ body {
background-color: floralwhite;
margin: 0;
font-family: system-ui;
+ text-align: center;
}
h1 {
- text-align: center;
background-color: #f7c7867d;
margin: 0 0 0.2em 0;
box-shadow: 0px 0.2em 0.2em #f7c7867d;
@@ -21,9 +21,6 @@ hr {
.hidden {
display: none;
}
-.center {
- text-align: center;
-}
.content {
width: 100%;
display: flex;
@@ -55,7 +52,6 @@ hr {
border: 2px solid darkkhaki;
padding: 3px 6px;
text-decoration: none;
- text-align: center;
color: black;
}
.user-info a.logout:hover {
@@ -70,7 +66,6 @@ hr {
margin: 0;
}
.debug-auth p {
- text-align: center;
border-bottom: 1px solid black;
}
.debug-auth ul {
@@ -101,16 +96,24 @@ hr {
.hasResponseStatus.status-503 {
background-color: #ffA88050;
}
-.role {
+
+.role, .scope {
padding: 3px 6px;
- background-color: #44228840;
+ margin: 3px;
border-radius: 6px;
}
+.role {
+ background-color: #44228840;
+}
+
+.scope {
+ background-color: #8888FF80;
+}
+
/* For home */
.login-box {
- text-align: center;
background-color: antiquewhite;
margin: 0.5em auto;
width: fit-content;
@@ -137,7 +140,6 @@ hr {
max-height: 2em;
}
.providers .provider .link div {
- text-align: center;
background-color: #f7c7867d;
border-radius: 8px;
padding: 6px;
@@ -152,13 +154,11 @@ hr {
}
.providers .error {
padding: 3px 6px;
- text-align: center;
font-weight: bold;
flex: 1 1 auto;
}
.content .links-to-check {
display: flex;
- text-align: center;
justify-content: center;
gap: 0.5em;
flex-flow: wrap;
diff --git a/src/oidc_test/static/utils.js b/src/oidc_test/static/utils.js
index 6b40d3d..142fa6e 100644
--- a/src/oidc_test/static/utils.js
+++ b/src/oidc_test/static/utils.js
@@ -17,3 +17,24 @@ function checkPerms(className) {
Array.from(elem.children).forEach(elem => checkHref(elem))
)
}
+
+async function get_resource(id, token, authProvider) {
+ //if (!keycloak.keycloak) { return }
+ const resp = await fetch("resource/" + id, {
+ method: "GET",
+ headers: new Headers({
+ "Content-type": "application/json",
+ "Authorization": `Bearer ${token}`,
+ "auth_provider": authProvider,
+ }),
+ })
+ /*
+ resource.value = resp['data']
+ msg.value = ""
+ }
+ ).catch (
+ err => msg.value = err
+ )
+*/
+ console.log(await resp.json())
+}
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index c062101..bba2f2a 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -30,6 +30,10 @@
{% endif %}
{{ user.email }}
+
+ Provider:
+ {{ oidc_provider_settings.name }}
+
{% if user.roles %}
Roles:
@@ -38,17 +42,43 @@
{% endfor %}
{% endif %}
-
- Provider:
- {{ oidc_provider_settings.name }}
-
+ {% if user.access_token.scope %}
+
+ Scopes :
+ {% for scope in user.access_token.scope.split(' ') %}
+ {{ scope }}
+ {% endfor %}
+
+ {% endif %}
{% if oidc_provider_settings.account_url_template %}
- Account management
+
+ Account management
+
{% endif %}
Logout
{% endif %}
+
+ Fetch resources from the resource server with your authentication token:
+
+
+ Time
+ BS
+
+
+
+
+
{{ key }}
+
{{ value }}
+
{{ value }}
+
+
+
+ {{ msg }}
+
These links should get different response codes depending on the authorization:
From af49242192c29a38b58819ebd0f2e8ae55bd87b6 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 4 Feb 2025 02:27:32 +0100
Subject: [PATCH 11/70] Add self resouce provider
---
src/oidc_test/auth_utils.py | 25 ++++++++++++++++++-------
src/oidc_test/main.py | 1 +
src/oidc_test/resource_server.py | 24 +++++++++++++++---------
src/oidc_test/settings.py | 1 +
src/oidc_test/static/styles.css | 20 ++++++++++++++++++++
src/oidc_test/static/utils.js | 18 +++++++++++++++++-
src/oidc_test/templates/home.html | 17 +++++------------
7 files changed, 77 insertions(+), 29 deletions(-)
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py
index 4c3b98c..d96aba9 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth_utils.py
@@ -4,13 +4,12 @@ import logging
from fastapi import HTTPException, Request, Depends, status
from fastapi.security import OAuth2PasswordBearer
-from authlib.oauth2.rfc6749 import OAuth2Token
from authlib.integrations.starlette_client import OAuth, OAuthError, StarletteOAuth2App
from jwt import ExpiredSignatureError, InvalidKeyError
from httpx import AsyncClient
# from authlib.oauth1.auth import OAuthToken
-# from authlib.oauth2.auth import OAuth2Token
+from authlib.oauth2.auth import OAuth2Token
from .models import User
from .database import TokenNotInDb, db, UserNotInDB
@@ -21,7 +20,6 @@ logger = logging.getLogger(__name__)
oidc_providers_settings: dict[str, OIDCProvider] = dict(
[(provider.id, provider) for provider in settings.oidc.providers]
)
-
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@@ -37,9 +35,19 @@ async def fetch_token(name, request):
# return token.to_token()
-async def update_token(*args, **kwargs):
- logger.warn("TODO: update_token")
- ...
+async def update_token(name, token, refresh_token=None, access_token=None):
+ breakpoint()
+ if refresh_token:
+ item = OAuth2Token.find(name=name, refresh_token=refresh_token)
+ elif access_token:
+ item = OAuth2Token.find(name=name, access_token=access_token)
+ else:
+ return
+ # update old token
+ item.access_token = token["access_token"]
+ item.refresh_token = token.get("refresh_token")
+ item.expires_at = token["expires_at"]
+ item.save()
authlib_oauth = OAuth(cache=None, fetch_token=fetch_token, update_token=update_token)
@@ -52,7 +60,10 @@ def init_providers():
name=id,
server_metadata_url=provider.openid_configuration,
client_kwargs={
- "scope": "openid email offline_access profile",
+ "scope": " ".join(
+ ["openid", "email", "offline_access", "profile"]
+ + provider.resource_provider_scopes
+ ),
},
client_id=provider.client_id,
client_secret=provider.client_secret,
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 3d95009..e14b4a8 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -102,6 +102,7 @@ async def home(
context={
"settings": settings.model_dump(),
"user": user,
+ "access_token_scope": user.access_token_parsed()["scope"] if user else None,
"now": now,
"oidc_provider": oidc_provider,
"oidc_provider_settings": oidc_provider_settings,
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index ecaa597..fbee866 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -2,6 +2,7 @@ from datetime import datetime
import logging
from httpx import AsyncClient
+from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from .models import User
@@ -22,15 +23,20 @@ async def get_resource(resource_id: str, user: User) -> dict:
# 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
- if user.has_scope(required_scope):
- await process(user, resource_id, resp)
- else:
- ## For the showcase, giving a explanation.
- ## Alternatively, raise HTTP_401_UNAUTHORIZED
- resp["sorry"] = (
- f"No scope {required_scope} in the access token "
- + "but it is required for accessing this resource."
- )
+ 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
+ resp["sorry"] = (
+ f"No scope {required_scope} in the access token "
+ + "but it is required for accessing this resource."
+ )
+ except ExpiredSignatureError:
+ resp["sorry"] = "The token's signature has expired"
+ except InvalidTokenError:
+ resp["sorry"] = "The token is invalid"
return resp
diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py
index 46d857d..4d08ada 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -45,6 +45,7 @@ class OIDCProvider(BaseModel):
)
public_key: str | None = None
signature_alg: str = "RS256"
+ resource_provider_scopes: list[str] = []
@computed_field
@property
diff --git a/src/oidc_test/static/styles.css b/src/oidc_test/static/styles.css
index 4552ca0..b0753b7 100644
--- a/src/oidc_test/static/styles.css
+++ b/src/oidc_test/static/styles.css
@@ -170,3 +170,23 @@ hr {
border-radius: 8px;
}
+.resource {
+ padding: 0.5em;
+ display: flex;
+ gap: 0.5em;
+ flex-direction: column;
+ width: fit-content;
+ align-items: center;
+ margin: 5px auto;
+ box-shadow: 0px 0px 10px #90c3eeA0;
+ background-color: #90c3eeA0;
+ border-radius: 8px;
+}
+
+.resources {
+ display: flex;
+}
+
+.key {
+ font-weight: bold;
+}
diff --git a/src/oidc_test/static/utils.js b/src/oidc_test/static/utils.js
index 142fa6e..9cc8040 100644
--- a/src/oidc_test/static/utils.js
+++ b/src/oidc_test/static/utils.js
@@ -36,5 +36,21 @@ async function get_resource(id, token, authProvider) {
err => msg.value = err
)
*/
- console.log(await resp.json())
+ const resource = await resp.json()
+ const rootElem = document.getElementById('resource')
+ rootElem.innerHTML = ""
+ Object.entries(resource).forEach(
+ ([k, v]) => {
+ let r = document.createElement('div')
+ let kElem = document.createElement('div')
+ kElem.innerText = k
+ kElem.className = "key"
+ let vElem = document.createElement('div')
+ vElem.innerText = v
+ vElem.className = "value"
+ r.appendChild(kElem)
+ r.appendChild(vElem)
+ rootElem.appendChild(r)
+ }
+ )
}
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index bba2f2a..55bd844 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -42,10 +42,10 @@
{% endfor %}
{% endif %}
- {% if user.access_token.scope %}
+ {% if access_token_scope %}
Scopes :
- {% for scope in user.access_token.scope.split(' ') %}
+ {% for scope in access_token_scope.split(' ') %}
{{ scope }}
{% endfor %}
@@ -61,6 +61,7 @@
{% endif %}
+ {% if user %}
Fetch resources from the resource server with your authentication token:
@@ -68,17 +69,9 @@
Time
BS
-
-
-
-
{{ key }}
-
{{ value }}
-
{{ value }}
-
-
-
- {{ msg }}
+
+ {% endif %}
These links should get different response codes depending on the authorization:
From fefe44acfef0a16c492070ac8bc9f43d4637aa2c Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 4 Feb 2025 03:03:28 +0100
Subject: [PATCH 12/70] CI: build only if git clean tag
---
.forgejo/workflows/build.yaml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml
index df52e0b..e02bf47 100644
--- a/.forgejo/workflows/build.yaml
+++ b/.forgejo/workflows/build.yaml
@@ -48,11 +48,13 @@ jobs:
run: sed "s/0.0.0/$VERSION/" -i pyproject.toml
- name: Workaround for bug of podman-login
+ if: fromJSON(steps.builder.outputs.run)
run: |
mkdir -p $HOME/.docker
echo "{ \"auths\": {} }" > $HOME/.docker/config.json
- name: Log in to the container registry (with another workaround)
+ if: fromJSON(steps.builder.outputs.run)
uses: actions/podman-login@v1
with:
registry: ${{ vars.REGISTRY }}
@@ -61,6 +63,7 @@ jobs:
auth_file_path: /tmp/auth.json
- name: Build the container image
+ if: fromJSON(steps.builder.outputs.run)
uses: actions/buildah-build@v1
with:
image: oidc-fastapi-test
@@ -71,6 +74,7 @@ jobs:
./Containerfile
- name: Push the image to the registry
+ if: fromJSON(steps.builder.outputs.run)
uses: actions/push-to-registry@v2
with:
registry: "docker://${{ vars.REGISTRY }}/${{ vars.ORGANISATION }}"
@@ -78,9 +82,11 @@ jobs:
tags: latest ${{ steps.version.outputs.version }}
- name: Build wheel
+ if: fromJSON(steps.builder.outputs.run)
run: uv build --wheel
- name: Publish Python package (home)
+ if: fromJSON(steps.builder.outputs.run)
env:
LOCAL_PYPI_TOKEN: ${{ secrets.LOCAL_PYPI_TOKEN }}
run: uv publish --publish-url https://code.philo.ydns.eu/api/packages/philorg/pypi --token $LOCAL_PYPI_TOKEN
From aa86f8135843232739df3790aa137b3da4d78f85 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 4 Feb 2025 03:38:33 +0100
Subject: [PATCH 13/70] Fix home when token cannot be decoded
---
src/oidc_test/main.py | 27 +++++++++++++++++++++++----
src/oidc_test/models.py | 11 +++++++++--
2 files changed, 32 insertions(+), 6 deletions(-)
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index e14b4a8..aac258b 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -15,6 +15,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
+from jwt import InvalidKeyError, InvalidTokenError
from starlette.middleware.sessions import SessionMiddleware
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
from authlib.integrations.base_client import OAuthError
@@ -96,13 +97,24 @@ async def home(
else:
resources = []
oidc_provider_settings = None
+
+ if user is None:
+ access_token_scope = None
+ else:
+ try:
+ access_token_scope = user.decode_access_token()["scope"]
+ except InvalidTokenError as err:
+ access_token_scope = None
+ logger.info("Invalid token")
+ logger.exception(err)
+
return templates.TemplateResponse(
name="home.html",
request=request,
context={
"settings": settings.model_dump(),
"user": user,
- "access_token_scope": user.access_token_parsed()["scope"] if user else None,
+ "access_token_scope": access_token_scope,
"now": now,
"oidc_provider": oidc_provider,
"oidc_provider_settings": oidc_provider_settings,
@@ -187,15 +199,22 @@ async def auth(request: Request, oidc_provider_id: str) -> RedirectResponse:
user_info_from_endpoint = {}
# Build and remember the user in the session
request.session["user_sub"] = sub
- # Store the user in the database
+ # Verify the token's signature and validity
try:
oidc_provider_settings = oidc_providers_settings[oidc_provider_id]
- access_token = oidc_provider_settings.decode(token["access_token"])
- except Exception:
+ oidc_provider_settings.decode(token["access_token"])
+ except InvalidKeyError:
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED,
+ detail="Token invalid key / signature",
+ )
+ except Exception as err:
+ logger.exception(err)
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
detail="Cannot decode token or verify its signature",
)
+ # Store the user in the database
user = await db.add_user(
sub,
user_info=userinfo,
diff --git a/src/oidc_test/models.py b/src/oidc_test/models.py
index db5d6ad..4b1c064 100644
--- a/src/oidc_test/models.py
+++ b/src/oidc_test/models.py
@@ -1,3 +1,4 @@
+import logging
from functools import cached_property
from typing import Self
@@ -10,6 +11,8 @@ from pydantic import (
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
from sqlmodel import SQLModel, Field
+logger = logging.getLogger(__name__)
+
class Role(SQLModel, extra="ignore"):
name: str
@@ -54,10 +57,14 @@ class User(UserBase):
def has_scope(self, scope: str) -> bool:
"""Check if the scope is present in user info or access token"""
info_scopes = self.userinfo.get("scope", "").split(" ")
- access_token_scopes = self.access_token_parsed().get("scope", "").split(" ")
+ try:
+ access_token_scopes = self.decode_access_token().get("scope", "").split(" ")
+ except Exception as err:
+ logger.info(f"Access token cannot be decoded: {err}")
+ access_token_scopes = []
return scope in set(info_scopes + access_token_scopes)
- def access_token_parsed(self):
+ def decode_access_token(self):
assert self.access_token is not None
assert self.oidc_provider is not None
assert self.oidc_provider.name is not None
From 31a783cbf19be0fe2e78f1fdb053c485d379d3a1 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 4 Feb 2025 18:03:17 +0100
Subject: [PATCH 14/70] Fix token error handling
---
src/oidc_test/auth_utils.py | 7 +++----
src/oidc_test/database.py | 4 +++-
src/oidc_test/main.py | 8 +++++---
3 files changed, 11 insertions(+), 8 deletions(-)
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py
index d96aba9..fd82ecd 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth_utils.py
@@ -113,8 +113,7 @@ async def get_current_user(request: Request) -> User:
"""
if (user_sub := request.session.get("user_sub")) is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
- if (token := await db.get_token(request.session["token"])) is None:
- raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token unknown")
+ token = await get_token(request)
user = await db.get_user(user_sub)
## Check if the token is expired
if token.is_expired():
@@ -138,8 +137,8 @@ async def get_token(request: Request) -> OAuth2Token:
"""Return the token from a request object, from the session.
It can be used in Depends()"""
try:
- return await db.get_token(request.session["token"])
- except (KeyError, TokenNotInDb):
+ return await db.get_token(request.session.get("token"))
+ except TokenNotInDb:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No token")
diff --git a/src/oidc_test/database.py b/src/oidc_test/database.py
index 1b682ef..0a30e9c 100644
--- a/src/oidc_test/database.py
+++ b/src/oidc_test/database.py
@@ -54,7 +54,9 @@ class Database:
async def add_token(self, token: OAuth2Token, user: User) -> None:
self.tokens[token["id_token"]] = token
- async def get_token(self, id_token: str) -> OAuth2Token:
+ async def get_token(self, id_token: str | None) -> OAuth2Token:
+ if id_token is None:
+ raise TokenNotInDb
try:
return self.tokens[id_token]
except KeyError:
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index aac258b..e9ba5b1 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -41,7 +41,7 @@ from .auth_utils import (
get_providers_info,
)
from .auth_misc import pretty_details
-from .database import db
+from .database import TokenNotInDb, db
from .resource_server import get_resource
logger = logging.getLogger("uvicorn.error")
@@ -268,8 +268,10 @@ async def logout(
)
return RedirectResponse(request.url_for("non_compliant_logout"))
post_logout_uri = request.url_for("home")
- if (token := await db.get_token(request.session.pop("token", None))) is None:
- logger.warn("No session in db for the token")
+ try:
+ token = await db.get_token(request.session.pop("token", None))
+ except TokenNotInDb:
+ logger.warn("No session in db for the token or no token")
return RedirectResponse(request.url_for("home"))
logout_url = (
provider_logout_uri
From 3dc14ae57ba9aaafeff97a6ed3e20e8f1b35f4f8 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 4 Feb 2025 18:19:58 +0100
Subject: [PATCH 15/70] Cosmetic
---
src/oidc_test/static/styles.css | 38 +++++++++++++++++++++++++++++++++
src/oidc_test/static/utils.js | 3 +++
2 files changed, 41 insertions(+)
diff --git a/src/oidc_test/static/styles.css b/src/oidc_test/static/styles.css
index b0753b7..426a464 100644
--- a/src/oidc_test/static/styles.css
+++ b/src/oidc_test/static/styles.css
@@ -21,6 +21,12 @@ hr {
.hidden {
display: none;
}
+.center {
+ text-align: center;
+}
+.error {
+ color: darkred;
+}
.content {
width: 100%;
display: flex;
@@ -111,6 +117,7 @@ hr {
background-color: #8888FF80;
}
+
/* For home */
.login-box {
@@ -170,6 +177,17 @@ hr {
border-radius: 8px;
}
+.token {
+ overflow-wrap: anywhere;
+ font-family: monospace;
+}
+
+.actions {
+ display: flex;
+ justify-content: center;
+ gap: 0.5em;
+}
+
.resource {
padding: 0.5em;
display: flex;
@@ -187,6 +205,26 @@ hr {
display: flex;
}
+.resource {
+ text-align: center;
+}
+
+.token-info {
+ margin: 0 1em;
+}
+
.key {
font-weight: bold;
}
+
+.token .key, .token .value {
+ display: inline;
+}
+.token .value {
+ padding-left: 1em;
+}
+
+.msg {
+ text-align: center;
+ font-weight: bold;
+}
diff --git a/src/oidc_test/static/utils.js b/src/oidc_test/static/utils.js
index 9cc8040..2fdb32d 100644
--- a/src/oidc_test/static/utils.js
+++ b/src/oidc_test/static/utils.js
@@ -48,6 +48,9 @@ async function get_resource(id, token, authProvider) {
let vElem = document.createElement('div')
vElem.innerText = v
vElem.className = "value"
+ if (k == "sorry") {
+ vElem.classList.add("error")
+ }
r.appendChild(kElem)
r.appendChild(vElem)
rootElem.appendChild(r)
From b86ae4eb112ec142ba816cb391ae4f8baad54b60 Mon Sep 17 00:00:00 2001
From: phil
Date: Wed, 5 Feb 2025 02:13:09 +0100
Subject: [PATCH 16/70] Raise HTTPException on resource server error
---
src/oidc_test/auth_utils.py | 1 +
src/oidc_test/resource_server.py | 17 ++++++++++++-----
src/oidc_test/static/styles.css | 4 ++--
src/oidc_test/static/utils.js | 25 +++++++++++++++----------
src/oidc_test/templates/home.html | 8 ++++++--
5 files changed, 36 insertions(+), 19 deletions(-)
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py
index fd82ecd..0c8dcc7 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth_utils.py
@@ -37,6 +37,7 @@ async def fetch_token(name, request):
async def update_token(name, token, refresh_token=None, access_token=None):
breakpoint()
+ item = await db.get_token(token["id_token"])
if refresh_token:
item = OAuth2Token.find(name=name, refresh_token=refresh_token)
elif access_token:
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index fbee866..635a91b 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -3,6 +3,8 @@ import logging
from httpx import AsyncClient
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
+from fastapi import HTTPException, status
+from starlette.status import HTTP_401_UNAUTHORIZED
from .models import User
@@ -29,14 +31,17 @@ async def get_resource(resource_id: str, user: User) -> dict:
else:
## For the showcase, giving a explanation.
## Alternatively, raise HTTP_401_UNAUTHORIZED
- resp["sorry"] = (
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED,
f"No scope {required_scope} in the access token "
- + "but it is required for accessing this resource."
+ + "but it is required for accessing this resource.",
)
except ExpiredSignatureError:
- resp["sorry"] = "The token's signature has expired"
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED, "The token's signature has expired"
+ )
except InvalidTokenError:
- resp["sorry"] = "The token is invalid"
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "The token is invalid")
return resp
@@ -53,7 +58,9 @@ async def process(user, resource_id, resp):
bs = await client.get("https://corporatebs-generator.sameerkumar.website/")
resp["bs"] = bs.json().get("phrase", "Sorry, i am out of BS today.")
else:
- resp["sorry"] = f"I don't known how to give '{resource_id}'."
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED, f"I don't known how to give '{resource_id}'."
+ )
# assert user.oidc_provider is not None
diff --git a/src/oidc_test/static/styles.css b/src/oidc_test/static/styles.css
index 426a464..7e1260b 100644
--- a/src/oidc_test/static/styles.css
+++ b/src/oidc_test/static/styles.css
@@ -73,6 +73,7 @@ hr {
}
.debug-auth p {
border-bottom: 1px solid black;
+ text-align: left;
}
.debug-auth ul {
padding: 0;
@@ -188,9 +189,8 @@ hr {
gap: 0.5em;
}
-.resource {
+.resourceResult {
padding: 0.5em;
- display: flex;
gap: 0.5em;
flex-direction: column;
width: fit-content;
diff --git a/src/oidc_test/static/utils.js b/src/oidc_test/static/utils.js
index 2fdb32d..a982267 100644
--- a/src/oidc_test/static/utils.js
+++ b/src/oidc_test/static/utils.js
@@ -20,6 +20,8 @@ function checkPerms(className) {
async function get_resource(id, token, authProvider) {
//if (!keycloak.keycloak) { return }
+ const msg = document.getElementById("msg")
+ const resourceElem = document.getElementById('resource')
const resp = await fetch("resource/" + id, {
method: "GET",
headers: new Headers({
@@ -27,18 +29,21 @@ async function get_resource(id, token, authProvider) {
"Authorization": `Bearer ${token}`,
"auth_provider": authProvider,
}),
+ }).catch(err => {
+ msg.innerHTML = "Cannot fetch resource: " + err.message
+ resourceElem.innerHTML = ""
})
- /*
- resource.value = resp['data']
- msg.value = ""
+ if (resp === undefined) {
+ return
}
- ).catch (
- err => msg.value = err
- )
-*/
const resource = await resp.json()
- const rootElem = document.getElementById('resource')
- rootElem.innerHTML = ""
+ if (!resp.ok) {
+ msg.innerHTML = resource["detail"]
+ resourceElem.innerHTML = ""
+ return
+ }
+ msg.innerHTML = ""
+ resourceElem.innerHTML = ""
Object.entries(resource).forEach(
([k, v]) => {
let r = document.createElement('div')
@@ -53,7 +58,7 @@ async function get_resource(id, token, authProvider) {
}
r.appendChild(kElem)
r.appendChild(vElem)
- rootElem.appendChild(r)
+ resourceElem.appendChild(r)
}
)
}
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index 55bd844..ce344cc 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -69,7 +69,10 @@
Time
BS
-
+
{% endif %}
@@ -96,6 +99,7 @@
{% endfor %}
{% endif %}
+
{% if user_info_details %}
@@ -103,7 +107,7 @@
{% for key, value in user_info_details.items() %}
- {{ key }} : {{ value }}
+ {{ key }} : {{ value }}
{% endfor %}
From 76da695b66d306eda3e57cf0a562d33b4c004ac9 Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 6 Feb 2025 13:27:14 +0100
Subject: [PATCH 17/70] Set black config - line length
---
pyproject.toml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/pyproject.toml b/pyproject.toml
index 980bcfc..b1e6504 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,3 +35,6 @@ packages = ["src/oidc_test"]
[tool.uv]
package = true
+
+[tool.black]
+line-length = 98
From 5c9ed9724e1a53bee8230adeca3174e0ccc8c086 Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 6 Feb 2025 13:27:45 +0100
Subject: [PATCH 18/70] Add logging conf for debugging
---
log_conf.yaml | 34 ++++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
create mode 100644 log_conf.yaml
diff --git a/log_conf.yaml b/log_conf.yaml
new file mode 100644
index 0000000..a6bb0b4
--- /dev/null
+++ b/log_conf.yaml
@@ -0,0 +1,34 @@
+version: 1
+disable_existing_loggers: False
+formatters:
+ default:
+ "()": uvicorn.logging.DefaultFormatter
+ format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+ access:
+ "()": uvicorn.logging.AccessFormatter
+ format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+handlers:
+ default:
+ formatter: default
+ class: logging.StreamHandler
+ stream: ext://sys.stderr
+ access:
+ formatter: access
+ class: logging.StreamHandler
+ stream: ext://sys.stdout
+loggers:
+ uvicorn.error:
+ level: INFO
+ handlers:
+ - default
+ propagate: no
+ uvicorn.access:
+ level: INFO
+ handlers:
+ - access
+ propagate: no
+ "oidc-test":
+ level: DEBUG
+ handlers:
+ - default
+ propagate: yes
From ee8ba3d2df9bcf0baa0f799aac00ac9e010cfd5d Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 6 Feb 2025 13:30:35 +0100
Subject: [PATCH 19/70] Get roles from access token, remove user info
inspection, refreactorings
---
src/oidc_test/auth_utils.py | 56 ++++++++++++++++-------------
src/oidc_test/database.py | 58 ++++++++++++++++++++----------
src/oidc_test/main.py | 60 +++++++++++++-------------------
src/oidc_test/models.py | 18 +++-------
src/oidc_test/resource_server.py | 2 +-
src/oidc_test/settings.py | 29 ++++++++++++---
6 files changed, 126 insertions(+), 97 deletions(-)
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py
index 0c8dcc7..281511d 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth_utils.py
@@ -13,13 +13,10 @@ from authlib.oauth2.auth import OAuth2Token
from .models import User
from .database import TokenNotInDb, db, UserNotInDB
-from .settings import settings, OIDCProvider
+from .settings import settings, OIDCProvider, oidc_providers_settings
-logger = logging.getLogger(__name__)
+logger = logging.getLogger("oidc-test")
-oidc_providers_settings: dict[str, OIDCProvider] = dict(
- [(provider.id, provider) for provider in settings.oidc.providers]
-)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@@ -36,19 +33,16 @@ async def fetch_token(name, request):
async def update_token(name, token, refresh_token=None, access_token=None):
- breakpoint()
- item = await db.get_token(token["id_token"])
- if refresh_token:
- item = OAuth2Token.find(name=name, refresh_token=refresh_token)
- elif access_token:
- item = OAuth2Token.find(name=name, access_token=access_token)
- else:
- return
+ oidc_provider_settings = oidc_providers_settings[name]
+ sid: str = oidc_provider_settings.decode(token["id_token"])["sid"]
+ item = await db.get_token(oidc_provider_settings, sid)
# update old token
- item.access_token = token["access_token"]
- item.refresh_token = token.get("refresh_token")
- item.expires_at = token["expires_at"]
- item.save()
+ item["access_token"] = token.get("access_token")
+ item["refresh_token"] = token.get("refresh_token")
+ item["expires_at"] = token["expires_at"]
+ logger.info(f"Token {sid} refreshed")
+ # It's a fake db and only in memory, so there's nothing to save
+ # await item.save()
authlib_oauth = OAuth(cache=None, fetch_token=fetch_token, update_token=update_token)
@@ -138,8 +132,17 @@ async def get_token(request: Request) -> OAuth2Token:
"""Return the token from a request object, from the session.
It can be used in Depends()"""
try:
- return await db.get_token(request.session.get("token"))
- except TokenNotInDb:
+ oidc_provider_settings = oidc_providers_settings[
+ request.session.get("oidc_provider_id", "")
+ ]
+ except KeyError:
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid provider")
+ try:
+ return await db.get_token(
+ oidc_provider_settings,
+ request.session.get("sid"),
+ )
+ except (TokenNotInDb, InvalidKeyError):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No token")
@@ -190,14 +193,16 @@ async def get_user_from_token(
token: Annotated[str, Depends(oauth2_scheme)],
request: Request,
) -> User:
- if (auth_provider_id := request.headers.get("auth_provider")) is None:
+ try:
+ auth_provider_id = request.headers["auth_provider"]
+ except KeyError:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Request headers must have a 'auth_provider' field",
)
- if (
- auth_provider_settings := oidc_providers_settings.get(auth_provider_id)
- ) is None:
+ 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}'"
)
@@ -216,7 +221,9 @@ async def get_user_from_token(
logger.info("Cannot decode token, see below")
logger.exception(err)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Cannot decode token")
- if (user_id := payload.get("sub")) is None:
+ try:
+ user_id = payload["sub"]
+ except KeyError:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, "Wrong token: 'sub' (user id) not found"
)
@@ -232,7 +239,6 @@ async def get_user_from_token(
sub=payload["sub"],
user_info=payload,
oidc_provider=getattr(authlib_oauth, auth_provider_id),
- user_info_from_endpoint={},
access_token=token,
)
return user
diff --git a/src/oidc_test/database.py b/src/oidc_test/database.py
index 0a30e9c..360ef11 100644
--- a/src/oidc_test/database.py
+++ b/src/oidc_test/database.py
@@ -3,11 +3,12 @@
import logging
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
-
-from .models import User, Role
from authlib.oauth2.rfc6749 import OAuth2Token
-logger = logging.getLogger(__name__)
+from .settings import OIDCProvider, oidc_providers_settings
+from .models import User, Role
+
+logger = logging.getLogger("oidc-test")
class UserNotInDB(Exception):
@@ -29,20 +30,34 @@ class Database:
sub: str,
user_info: dict,
oidc_provider: StarletteOAuth2App,
- user_info_from_endpoint: dict,
access_token: str,
+ access_token_decoded: dict | None = None,
) -> User:
- user = User.from_auth(userinfo=user_info, oidc_provider=oidc_provider)
+ if access_token_decoded is None:
+ assert oidc_provider.name is not None
+ oidc_provider_settings = oidc_providers_settings[oidc_provider.name]
+ access_token_decoded = oidc_provider_settings.decode(access_token)
+ user = User(**user_info)
+ user.userinfo = user_info
+ user.oidc_provider = oidc_provider
user.access_token = access_token
+ user.access_token_decoded = access_token_decoded
+ # Add roles provided in the access token
+ roles = set()
try:
- raw_roles = user_info_from_endpoint["resource_access"][
- oidc_provider.client_id
- ]["roles"]
- except Exception as err:
- logger.debug(f"Cannot read additional roles: {err}")
- raw_roles = []
- for raw_role in raw_roles:
- user.roles.append(Role(name=raw_role))
+ r = access_token_decoded["resource_access"][oidc_provider.client_id]["roles"]
+ roles.update(r)
+ except KeyError:
+ pass
+ try:
+ r = access_token_decoded["realm_access"]["roles"]
+ if isinstance(r, str):
+ roles.add(r)
+ else:
+ roles.update(r)
+ except KeyError:
+ pass
+ user.roles = [Role(name=role_name) for role_name in roles]
self.users[sub] = user
return user
@@ -51,14 +66,21 @@ class Database:
raise UserNotInDB
return self.users[sub]
- async def add_token(self, token: OAuth2Token, user: User) -> None:
- self.tokens[token["id_token"]] = token
+ async def add_token(self, oidc_provider_settings: OIDCProvider, token: OAuth2Token) -> None:
+ """Store a token using as key the sid (auth provider's session id)
+ in the id_token"""
+ sid = token["userinfo"]["sid"]
+ self.tokens[sid] = token
- async def get_token(self, id_token: str | None) -> OAuth2Token:
- if id_token is None:
+ async def get_token(
+ self,
+ oidc_provider_settings: OIDCProvider,
+ sid: str | None,
+ ) -> OAuth2Token:
+ if sid is None:
raise TokenNotInDb
try:
- return self.tokens[id_token]
+ return self.tokens[sid]
except KeyError:
raise TokenNotInDb
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index e9ba5b1..60482bd 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -15,7 +15,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
-from jwt import InvalidKeyError, InvalidTokenError
+from jwt import InvalidTokenError, PyJWTError
from starlette.middleware.sessions import SessionMiddleware
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
from authlib.integrations.base_client import OAuthError
@@ -26,7 +26,7 @@ from authlib.oauth2.rfc6749 import OAuth2Token
# from fastapi.security import OpenIdConnect
# from pkce import generate_code_verifier, generate_pkce_pair
-from .settings import settings
+from .settings import settings, oidc_providers_settings
from .models import User
from .auth_utils import (
get_oidc_provider,
@@ -37,14 +37,13 @@ from .auth_utils import (
get_user_from_token,
authlib_oauth,
get_token,
- oidc_providers_settings,
get_providers_info,
)
from .auth_misc import pretty_details
from .database import TokenNotInDb, db
from .resource_server import get_resource
-logger = logging.getLogger("uvicorn.error")
+logger = logging.getLogger("oidc-test")
templates = Jinja2Templates(Path(__file__).parent / "templates")
@@ -189,43 +188,28 @@ async def auth(request: Request, oidc_provider_id: str) -> RedirectResponse:
request.session["oidc_provider_id"] = oidc_provider_id
# User id (sub) given by oidc provider
sub = userinfo["sub"]
- # Get additional data from userinfo endpoint
- try:
- user_info_from_endpoint = await oidc_provider.userinfo(
- token=token, follow_redirects=True
- )
- except Exception as err:
- logger.warn(f"Cannot get userinfo from endpoint: {err}")
- user_info_from_endpoint = {}
# Build and remember the user in the session
request.session["user_sub"] = sub
- # Verify the token's signature and validity
+ # Store the user in the database, which also verifies the token validity and signature
try:
- oidc_provider_settings = oidc_providers_settings[oidc_provider_id]
- oidc_provider_settings.decode(token["access_token"])
- except InvalidKeyError:
+ user = await db.add_user(
+ sub,
+ user_info=userinfo,
+ oidc_provider=oidc_provider,
+ access_token=token["access_token"],
+ )
+ except PyJWTError as err:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
- detail="Token invalid key / signature",
+ detail=f"Token invalid: {err.__class__.__name__}",
)
- except Exception as err:
- logger.exception(err)
- raise HTTPException(
- status.HTTP_401_UNAUTHORIZED,
- detail="Cannot decode token or verify its signature",
- )
- # Store the user in the database
- user = await db.add_user(
- sub,
- user_info=userinfo,
- oidc_provider=oidc_provider,
- user_info_from_endpoint=user_info_from_endpoint,
- access_token=token["access_token"],
- )
- # Add the id_token to the session
- request.session["token"] = token["id_token"]
+ assert isinstance(user, User)
+ # Add the provider session id to the session
+ request.session["sid"] = userinfo["sid"]
# Add the token to the db because it is used for logout
- await db.add_token(token, user)
+ assert oidc_provider.name is not None
+ oidc_provider_settings = oidc_providers_settings[oidc_provider.name]
+ await db.add_token(oidc_provider_settings, token)
# Send the user to the home: (s)he is authenticated
return RedirectResponse(url=request.url_for("home"))
else:
@@ -268,8 +252,14 @@ async def logout(
)
return RedirectResponse(request.url_for("non_compliant_logout"))
post_logout_uri = request.url_for("home")
+ oidc_provider_settings = oidc_providers_settings.get(
+ request.session.get("oidc_provider_id", "")
+ )
+ assert oidc_provider_settings is not None
try:
- token = await db.get_token(request.session.pop("token", None))
+ token = await db.get_token(
+ oidc_provider_settings, request.session.pop("sid", None)
+ )
except TokenNotInDb:
logger.warn("No session in db for the token or no token")
return RedirectResponse(request.url_for("home"))
diff --git a/src/oidc_test/models.py b/src/oidc_test/models.py
index 4b1c064..fc0dba7 100644
--- a/src/oidc_test/models.py
+++ b/src/oidc_test/models.py
@@ -1,6 +1,6 @@
import logging
from functools import cached_property
-from typing import Self
+from typing import Self, Any
from pydantic import (
computed_field,
@@ -11,7 +11,7 @@ from pydantic import (
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
from sqlmodel import SQLModel, Field
-logger = logging.getLogger(__name__)
+logger = logging.getLogger("oidc-test")
class Role(SQLModel, extra="ignore"):
@@ -36,19 +36,9 @@ class User(UserBase):
)
userinfo: dict = {}
access_token: str | None = None
+ access_token_decoded: dict[str, Any] | None = None
oidc_provider: StarletteOAuth2App | None = None
- @classmethod
- def from_auth(cls, userinfo: dict, oidc_provider: StarletteOAuth2App) -> Self:
- user = cls(**userinfo)
- user.userinfo = userinfo
- user.oidc_provider = oidc_provider
- # Add roles if they are provided in the token
- if raw_ra := userinfo.get("realm_access"):
- if raw_roles := raw_ra.get("roles"):
- user.roles = [Role(name=raw_role) for raw_role in raw_roles]
- return user
-
@computed_field
@cached_property
def roles_as_set(self) -> set[str]:
@@ -68,7 +58,7 @@ class User(UserBase):
assert self.access_token is not None
assert self.oidc_provider is not None
assert self.oidc_provider.name is not None
- from .auth_utils import oidc_providers_settings
+ from .settings import oidc_providers_settings
return oidc_providers_settings[self.oidc_provider.name].decode(
self.access_token
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index 635a91b..0d90533 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -8,7 +8,7 @@ from starlette.status import HTTP_401_UNAUTHORIZED
from .models import User
-logger = logging.getLogger(__name__)
+logger = logging.getLogger("oidc-test")
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 4d08ada..2544bd7 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -1,8 +1,9 @@
from os import environ
import string
import random
-from typing import Type, Tuple
+from typing import Type, Tuple, Any
from pathlib import Path
+import logging
from jwt import decode
from pydantic import BaseModel, computed_field, AnyUrl
@@ -16,6 +17,8 @@ from starlette.requests import Request
from .models import User
+logger = logging.getLogger("oidc-test")
+
class Resource(BaseModel):
"""A resource with an URL that can be accessed with an OAuth2 access token"""
@@ -86,14 +89,27 @@ class OIDCProvider(BaseModel):
-----END PUBLIC KEY-----
"""
- def decode(self, token: str) -> dict:
+ 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))
return decode(
token,
self.get_public_key(),
algorithms=[self.signature_alg],
- audience=["oidc-test", "oidc-test-web"],
- options={"verify_signature": not settings.insecure.skip_verify_signature},
+ audience=["account", "oidc-test", "oidc-test-web"],
+ options={
+ "verify_signature": verify_signature,
+ }, # not settings.insecure.skip_verify_signature},
)
@@ -156,3 +172,8 @@ class Settings(BaseSettings):
settings = Settings()
+
+
+oidc_providers_settings: dict[str, OIDCProvider] = dict(
+ [(provider.id, provider) for provider in settings.oidc.providers]
+)
From d39adf41eff5dac79bde268c01602f5f6072385d Mon Sep 17 00:00:00 2001
From: phil
Date: Fri, 7 Feb 2025 13:57:17 +0100
Subject: [PATCH 20/70] Create a sub-app for resource server move all resources
to resource server; use token bearer instead of session cookie for resources
and use fetch instead of XMLHttpRequest for checking resource status; add
UserWithRole class for fastapi depends (instead of has_role decorator); add
asserts for typing QC; code formatting; comment out introspect endpoint
processing
---
src/oidc_test/auth_utils.py | 28 ++++++-
src/oidc_test/database.py | 2 +
src/oidc_test/main.py | 133 ++++--------------------------
src/oidc_test/models.py | 6 +-
src/oidc_test/resource_server.py | 122 +++++++++++++++++++++++++--
src/oidc_test/static/utils.js | 32 ++++---
src/oidc_test/templates/base.html | 2 +-
src/oidc_test/templates/home.html | 16 ++--
8 files changed, 188 insertions(+), 153 deletions(-)
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py
index 281511d..1004527 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth_utils.py
@@ -13,7 +13,7 @@ from authlib.oauth2.auth import OAuth2Token
from .models import User
from .database import TokenNotInDb, db, UserNotInDB
-from .settings import settings, OIDCProvider, oidc_providers_settings
+from .settings import oidc_providers_settings
logger = logging.getLogger("oidc-test")
@@ -21,6 +21,8 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
async def fetch_token(name, request):
+ assert name is not None
+ assert request is not None
logger.warn("TODO: fetch_token")
...
# if name in oidc_providers:
@@ -37,8 +39,10 @@ async def update_token(name, token, refresh_token=None, access_token=None):
sid: str = oidc_provider_settings.decode(token["id_token"])["sid"]
item = await db.get_token(oidc_provider_settings, sid)
# update old token
- item["access_token"] = token.get("access_token")
- item["refresh_token"] = token.get("refresh_token")
+ if access_token is not None:
+ item["access_token"] = token.get("access_token")
+ if refresh_token is not None:
+ item["refresh_token"] = refresh_token
item["expires_at"] = token["expires_at"]
logger.info(f"Token {sid} refreshed")
# It's a fake db and only in memory, so there's nothing to save
@@ -119,6 +123,7 @@ async def get_current_user(request: Request) -> User:
userinfo = await oidc_provider.fetch_access_token(
refresh_token=token.get("refresh_token")
)
+ assert userinfo is not None
except OAuthError as err:
logger.exception(err)
# raise HTTPException(
@@ -242,3 +247,20 @@ async def get_user_from_token(
access_token=token,
)
return user
+
+
+class UserWithRole:
+ roles: set[str]
+
+ def __init__(self, roles: str | list[str] | tuple[str] | set[str]):
+ if isinstance(roles, str):
+ self.roles = set([roles])
+ elif isinstance(roles, (list, tuple, set)):
+ self.roles = set(roles)
+
+ def __call__(self, user: User = Depends(get_user_from_token)) -> User:
+ if not any(self.roles.intersection(user.roles_as_set)):
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED, f"Not of any required role {', '.join(self.roles)}"
+ )
+ return user
diff --git a/src/oidc_test/database.py b/src/oidc_test/database.py
index 360ef11..d3bdd4e 100644
--- a/src/oidc_test/database.py
+++ b/src/oidc_test/database.py
@@ -69,6 +69,7 @@ class Database:
async def add_token(self, oidc_provider_settings: OIDCProvider, token: OAuth2Token) -> None:
"""Store a token using as key the sid (auth provider's session id)
in the id_token"""
+ assert isinstance(oidc_provider_settings, OIDCProvider)
sid = token["userinfo"]["sid"]
self.tokens[sid] = token
@@ -77,6 +78,7 @@ class Database:
oidc_provider_settings: OIDCProvider,
sid: str | None,
) -> OAuth2Token:
+ assert isinstance(oidc_provider_settings, OIDCProvider)
if sid is None:
raise TokenNotInDb
try:
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 60482bd..47d0c39 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -12,7 +12,7 @@ from contextlib import asynccontextmanager
from httpx import HTTPError
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.staticfiles import StaticFiles
-from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
+from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
from jwt import InvalidTokenError, PyJWTError
@@ -31,17 +31,13 @@ from .models import User
from .auth_utils import (
get_oidc_provider,
get_oidc_provider_or_none,
- hasrole,
get_current_user_or_none,
- get_current_user,
- get_user_from_token,
authlib_oauth,
- get_token,
get_providers_info,
)
from .auth_misc import pretty_details
from .database import TokenNotInDb, db
-from .resource_server import get_resource
+from .resource_server import resource_server
logger = logging.getLogger("oidc-test")
@@ -50,6 +46,7 @@ templates = Jinja2Templates(Path(__file__).parent / "templates")
@asynccontextmanager
async def lifespan(app: FastAPI):
+ assert app is not None
await get_providers_info()
yield
@@ -64,24 +61,21 @@ app.add_middleware(
allow_headers=["*"],
)
-app.mount(
- "/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static"
-)
-
# SessionMiddleware is required by authlib
app.add_middleware(
SessionMiddleware,
secret_key=settings.secret_key,
)
+app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
+app.mount("/resource", resource_server, name="resource_server")
+
@app.get("/")
async def home(
request: Request,
user: Annotated[User, Depends(get_current_user_or_none)],
- oidc_provider: Annotated[
- StarletteOAuth2App | None, Depends(get_oidc_provider_or_none)
- ],
+ oidc_provider: Annotated[StarletteOAuth2App | None, Depends(get_oidc_provider_or_none)],
) -> HTMLResponse:
now = datetime.now()
if oidc_provider and (
@@ -119,9 +113,7 @@ async def home(
"oidc_provider_settings": oidc_provider_settings,
"resources": resources,
"user_info_details": (
- pretty_details(user, now)
- if user and settings.oidc.show_session_details
- else None
+ pretty_details(user, now) if user and settings.oidc.show_session_details else None
),
},
)
@@ -215,24 +207,19 @@ async def auth(request: Request, oidc_provider_id: str) -> RedirectResponse:
else:
# Not sure if it's correct to redirect to plain login
# if no userinfo is provided
- return RedirectResponse(
- url=request.url_for("login", oidc_provider_id=oidc_provider_id)
- )
+ return RedirectResponse(url=request.url_for("login", oidc_provider_id=oidc_provider_id))
@app.get("/account")
async def account(
request: Request,
- oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
) -> RedirectResponse:
if (
oidc_provider_settings := oidc_providers_settings.get(
request.session.get("oidc_provider_id", "")
)
) is None:
- raise HTTPException(
- status.HTTP_406_NOT_ACCEPTABLE, detail="No oidc provider settings"
- )
+ raise HTTPException(status.HTTP_406_NOT_ACCEPTABLE, detail="No oidc provider settings")
return RedirectResponse(f"{oidc_provider_settings.account_url_template}")
@@ -244,12 +231,8 @@ async def logout(
# Clear session
request.session.pop("user_sub", None)
# Get provider's endpoint
- if (
- provider_logout_uri := oidc_provider.server_metadata.get("end_session_endpoint")
- ) is None:
- logger.warn(
- f"Cannot find end_session_endpoint for provider {oidc_provider.name}"
- )
+ if (provider_logout_uri := oidc_provider.server_metadata.get("end_session_endpoint")) is None:
+ logger.warn(f"Cannot find end_session_endpoint for provider {oidc_provider.name}")
return RedirectResponse(request.url_for("non_compliant_logout"))
post_logout_uri = request.url_for("home")
oidc_provider_settings = oidc_providers_settings.get(
@@ -257,9 +240,7 @@ async def logout(
)
assert oidc_provider_settings is not None
try:
- token = await db.get_token(
- oidc_provider_settings, request.session.pop("sid", None)
- )
+ token = await db.get_token(oidc_provider_settings, request.session.pop("sid", None))
except TokenNotInDb:
logger.warn("No session in db for the token or no token")
return RedirectResponse(request.url_for("home"))
@@ -292,90 +273,6 @@ async def non_compliant_logout(
)
-# Route for OAuth resource server
-
-
-@app.get("/resource/{id}")
-async def get_resource_(
- id: str,
- # user: Annotated[User, Depends(get_current_user)],
- # 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))
-
-
-# Routes for RBAC based tests
-
-
-@app.get("/public")
-async def public() -> HTMLResponse:
- return HTMLResponse("Not protected ")
-
-
-@app.get("/protected")
-async def get_protected(
- user: Annotated[User, Depends(get_current_user)]
-) -> HTMLResponse:
- assert user is not None # Just to keep QA checks happy
- return HTMLResponse("Only authenticated users can see this ")
-
-
-@app.get("/protected-by-foorole")
-@hasrole("foorole")
-async def get_protected_by_foorole(request: Request) -> HTMLResponse:
- assert request is not None # Just to keep QA checks happy
- return HTMLResponse("Only users with foorole can see this ")
-
-
-@app.get("/protected-by-barrole")
-@hasrole("barrole")
-async def get_protected_by_barrole(request: Request) -> HTMLResponse:
- assert request is not None # Just to keep QA checks happy
- return HTMLResponse("Protected by barrole ")
-
-
-@app.get("/protected-by-foorole-and-barrole")
-@hasrole("barrole")
-@hasrole("foorole")
-async def get_protected_by_foorole_and_barrole(request: Request) -> HTMLResponse:
- assert request is not None # Just to keep QA checks happy
- return HTMLResponse("Only users with foorole and barrole can see this ")
-
-
-@app.get("/protected-by-foorole-or-barrole")
-@hasrole(["foorole", "barrole"])
-async def get_protected_by_foorole_or_barrole(request: Request) -> HTMLResponse:
- assert request is not None # Just to keep QA checks happy
- return HTMLResponse("Only users with foorole or barrole can see this ")
-
-
-@app.get("/introspect")
-async def get_introspect(
- request: Request,
- oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
- token: Annotated[OAuth2Token, Depends(get_token)],
-) -> JSONResponse:
- assert request is not None # Just to keep QA checks happy
- if (url := oidc_provider.server_metadata.get("introspection_endpoint")) is None:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="No intrispection endpoint found for the OIDC provider",
- )
- if (
- response := await oidc_provider.post(
- url,
- token=token,
- data={"token": token["access_token"]},
- )
- ).is_success:
- return response.json()
- else:
- raise HTTPException(status_code=response.status_code, detail=response.text)
-
-
# Snippet for running standalone
# Mostly useful for the --version option,
# as running with uvicorn is easy and provides better flexibility, eg.
@@ -397,9 +294,7 @@ def main():
parser.add_argument(
"-p", "--port", type=int, default=80, help="Port to listen to (default: 80)"
)
- parser.add_argument(
- "-v", "--version", action="store_true", help="Print version and exit"
- )
+ parser.add_argument("-v", "--version", action="store_true", help="Print version and exit")
args = parser.parse_args()
if args.version:
diff --git a/src/oidc_test/models.py b/src/oidc_test/models.py
index fc0dba7..9554bd5 100644
--- a/src/oidc_test/models.py
+++ b/src/oidc_test/models.py
@@ -1,6 +1,6 @@
import logging
from functools import cached_property
-from typing import Self, Any
+from typing import Any
from pydantic import (
computed_field,
@@ -60,6 +60,4 @@ class User(UserBase):
assert self.oidc_provider.name is not None
from .settings import oidc_providers_settings
- return oidc_providers_settings[self.oidc_provider.name].decode(
- self.access_token
- )
+ return oidc_providers_settings[self.oidc_provider.name].decode(self.access_token)
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index 0d90533..d5e2aaa 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -1,15 +1,127 @@
from datetime import datetime
+from typing import Annotated
import logging
from httpx import AsyncClient
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
-from fastapi import HTTPException, status
-from starlette.status import HTTP_401_UNAUTHORIZED
+from fastapi import FastAPI, HTTPException, Depends, Request, status
+from fastapi.responses import HTMLResponse, JSONResponse
+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 .models import User
+from .auth_utils import (
+ get_user_from_token,
+ UserWithRole,
+ get_oidc_provider,
+ get_token,
+)
+from .settings import settings
logger = logging.getLogger("oidc-test")
+resource_server = FastAPI()
+
+
+resource_server.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# SessionMiddleware is required by authlib
+# resource_server.add_middleware(
+# SessionMiddleware,
+# secret_key=settings.secret_key,
+# )
+
+# Route for OAuth resource server
+
+
+# Routes for RBAC based tests
+
+
+@resource_server.get("/public")
+async def public() -> HTMLResponse:
+ return HTMLResponse("Not protected ")
+
+
+@resource_server.get("/protected")
+async def get_protected(user: Annotated[User, Depends(get_user_from_token)]) -> HTMLResponse:
+ assert user is not None # Just to keep QA checks happy
+ return HTMLResponse("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 ")
+
+
+@resource_server.get("/protected-by-barrole")
+async def get_protected_by_barrole(
+ user: Annotated[User, Depends(UserWithRole("barrole"))]
+) -> HTMLResponse:
+ return HTMLResponse("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 ")
+
+
+@resource_server.get("/protected-by-foorole-or-barrole")
+async def get_protected_by_foorole_or_barrole(
+ user: Annotated[User, Depends(UserWithRole(["foorole", "barrole"]))]
+) -> HTMLResponse:
+ assert user is not None # Just to keep QA checks happy
+ return HTMLResponse("Only users with foorole or barrole can see this ")
+
+
+# @resource_server.get("/introspect")
+# async def get_introspect(
+# request: Request,
+# oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
+# token: Annotated[OAuth2Token, Depends(get_token)],
+# ) -> JSONResponse:
+# assert request is not None # Just to keep QA checks happy
+# if (url := oidc_provider.server_metadata.get("introspection_endpoint")) is None:
+# raise HTTPException(
+# status_code=status.HTTP_401_UNAUTHORIZED,
+# detail="No introspection endpoint found for the OIDC provider",
+# )
+# if (
+# response := await oidc_provider.post(
+# url,
+# token=token,
+# data={"token": token["access_token"]},
+# )
+# ).is_success:
+# return response.json()
+# 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_current_user)],
+ # 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))
+
async def get_resource(resource_id: str, user: User) -> dict:
"""
@@ -34,12 +146,10 @@ async def get_resource(resource_id: str, user: User) -> dict:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
f"No scope {required_scope} in the access token "
- + "but it is required for accessing this resource.",
+ + "but it is required for accessing this resource",
)
except ExpiredSignatureError:
- raise HTTPException(
- status.HTTP_401_UNAUTHORIZED, "The token's signature has expired"
- )
+ 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
diff --git a/src/oidc_test/static/utils.js b/src/oidc_test/static/utils.js
index a982267..e6c4bfc 100644
--- a/src/oidc_test/static/utils.js
+++ b/src/oidc_test/static/utils.js
@@ -1,20 +1,28 @@
-function checkHref(elem) {
- var xmlHttp = new XMLHttpRequest()
- xmlHttp.onreadystatechange = function () {
- if (xmlHttp.readyState == 4) {
- elem.classList.add("hasResponseStatus")
- elem.classList.add("status-" + xmlHttp.status)
- elem.title = "Response code: " + xmlHttp.status + " - " + xmlHttp.statusText
- }
+async function checkHref(elem, token, authProvider) {
+ const msg = document.getElementById("msg")
+ const resp = await fetch(elem.href, {
+ headers: new Headers({
+ "Content-type": "application/json",
+ "Authorization": `Bearer ${token}`,
+ "auth_provider": authProvider,
+ }),
+ }).catch(err => {
+ msg.innerHTML = "Cannot fetch resource: " + err.message
+ resourceElem.innerHTML = ""
+ })
+ if (resp === undefined) {
+ return
+ } else {
+ elem.classList.add("hasResponseStatus")
+ elem.classList.add("status-" + resp.status)
+ elem.title = "Response code: " + resp.status + " - " + resp.statusText
}
- xmlHttp.open("GET", elem.href, true) // true for asynchronous
- xmlHttp.send(null)
}
-function checkPerms(className) {
+function checkPerms(className, token, authProvider) {
var rootElems = document.getElementsByClassName(className)
Array.from(rootElems).forEach(elem =>
- Array.from(elem.children).forEach(elem => checkHref(elem))
+ Array.from(elem.children).forEach(elem => checkHref(elem, token, authProvider))
)
}
diff --git a/src/oidc_test/templates/base.html b/src/oidc_test/templates/base.html
index 3bdb3f3..0fe1a6b 100644
--- a/src/oidc_test/templates/base.html
+++ b/src/oidc_test/templates/base.html
@@ -4,7 +4,7 @@
-
+
OIDC-test - FastAPI client
{% block content %}
{% endblock %}
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index ce344cc..09c313f 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -80,14 +80,14 @@
These links should get different response codes depending on the authorization:
{% if resources %}
From 3eb6dc3dcf4be7350aa4cda6b3157183fe77d5d8 Mon Sep 17 00:00:00 2001
From: phil
Date: Fri, 7 Feb 2025 16:09:49 +0100
Subject: [PATCH 21/70] 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:
-
-
- Time
- BS
-
-
-
- {% endif %}
-
- These links should get different response codes depending on the authorization:
+
+ Resources validated by scope:
+
+ Resources validated by role:
+
+
+ Public
+ Auth protected content
+ Auth + foorole protected content
+ Auth + foorole or barrole protected content
+ Auth + barrole protected content
+ Auth + foorole and barrole protected content
+ Using FastAPI Depends
+
+
+
{% if resources %}
From ff72f0cae585858e400a9bc8f7d3fe1727035c44 Mon Sep 17 00:00:00 2001
From: phil
Date: Sat, 8 Feb 2025 01:55:36 +0100
Subject: [PATCH 22/70] Display full token info
---
src/oidc_test/auth_utils.py | 14 +++++++++--
src/oidc_test/main.py | 41 +++++++++++++++++++------------
src/oidc_test/settings.py | 1 +
src/oidc_test/static/styles.css | 4 +--
src/oidc_test/templates/home.html | 41 ++++++++++++++++++++++---------
5 files changed, 70 insertions(+), 31 deletions(-)
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py
index 3303e58..26f3779 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth_utils.py
@@ -1,3 +1,4 @@
+import re
from typing import Union, Annotated
from functools import wraps
import logging
@@ -133,9 +134,18 @@ async def get_current_user(request: Request) -> User:
return user
+async def get_token_or_none(request: Request) -> OAuth2Token | None:
+ """Return the auth token from the session or None.
+ Can be used in Depends()"""
+ try:
+ return await get_token(request)
+ except HTTPException:
+ return None
+
+
async def get_token(request: Request) -> OAuth2Token:
- """Return the token from a request object, from the session.
- It can be used in Depends()"""
+ """Return the token from the session.
+ Can be used in Depends()"""
try:
oidc_provider_settings = oidc_providers_settings[
request.session.get("oidc_provider_id", "")
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 47d0c39..4a037eb 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -34,6 +34,7 @@ from .auth_utils import (
get_current_user_or_none,
authlib_oauth,
get_providers_info,
+ get_token_or_none,
)
from .auth_misc import pretty_details
from .database import TokenNotInDb, db
@@ -76,6 +77,7 @@ async def home(
request: Request,
user: Annotated[User, Depends(get_current_user_or_none)],
oidc_provider: Annotated[StarletteOAuth2App | None, Depends(get_oidc_provider_or_none)],
+ token: Annotated[OAuth2Token | None, Depends(get_token_or_none)],
) -> HTMLResponse:
now = datetime.now()
if oidc_provider and (
@@ -101,22 +103,29 @@ async def home(
logger.info("Invalid token")
logger.exception(err)
- return templates.TemplateResponse(
- name="home.html",
- request=request,
- context={
- "settings": settings.model_dump(),
- "user": user,
- "access_token_scope": access_token_scope,
- "now": now,
- "oidc_provider": oidc_provider,
- "oidc_provider_settings": oidc_provider_settings,
- "resources": resources,
- "user_info_details": (
- pretty_details(user, now) if user and settings.oidc.show_session_details else None
- ),
- },
- )
+ context = {
+ "settings": settings.model_dump(),
+ "user": user,
+ "access_token_scope": access_token_scope,
+ "now": now,
+ "oidc_provider": oidc_provider,
+ "oidc_provider_settings": oidc_provider_settings,
+ "resources": resources,
+ }
+ if token is None:
+ context["id_token_parsed"] = None
+ context["access_token_parsed"] = None
+ context["refresh_token_parsed"] = None
+ else:
+ assert oidc_provider is not None
+ assert oidc_provider.name is not None
+ oidc_provider_settings = oidc_providers_settings[oidc_provider.name]
+ context["id_token_parsed"] = pretty_details(user, now)
+ context["access_token_parsed"] = oidc_provider_settings.decode(token["access_token"])
+ context["refresh_token_parsed"] = oidc_provider_settings.decode(
+ token["refresh_token"], verify_signature=False
+ )
+ return templates.TemplateResponse(name="home.html", request=request, context=context)
# Endpoints for the login / authorization process
diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py
index b601739..e448c1e 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -135,6 +135,7 @@ class Settings(BaseSettings):
insecure: Insecure = Insecure()
cors_origins: list[str] = []
debug_token: bool = False
+ show_token: bool = False
@classmethod
def settings_customise_sources(
diff --git a/src/oidc_test/static/styles.css b/src/oidc_test/static/styles.css
index 6262d79..367ea99 100644
--- a/src/oidc_test/static/styles.css
+++ b/src/oidc_test/static/styles.css
@@ -73,7 +73,6 @@ hr {
}
.debug-auth p {
border-bottom: 1px solid black;
- text-align: left;
}
.debug-auth ul {
padding: 0;
@@ -185,8 +184,9 @@ hr {
font-family: monospace;
}
-.resourceResult {
+.resource {
padding: 0.5em;
+ display: flex;
gap: 0.5em;
flex-direction: column;
width: fit-content;
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index 92b7068..9da5392 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -97,19 +97,38 @@
{% endif %}
- {% if user_info_details %}
-
-
{% endif %}
@@ -66,21 +67,21 @@
Resources validated by scope:
- Time
- BS
+ Time
+ BS
Resources validated by role:
- Public
- Auth protected content
- Auth + foorole protected content
- Auth + foorole or barrole protected content
- Auth + barrole protected content
- Auth + foorole and barrole protected content
- Using FastAPI Depends
-
+ Public
+ Auth protected content
+ Auth + foorole protected content
+ Auth + foorole or barrole protected content
+ Auth + barrole protected content
+ Auth + foorole and barrole protected content
+ Using FastAPI Depends
+
From 38b983c2a51ff1866e3306896a9e5e960bbe984b Mon Sep 17 00:00:00 2001
From: phil
Date: Sat, 8 Feb 2025 19:05:13 +0100
Subject: [PATCH 24/70] Fix scope
---
src/oidc_test/main.py | 19 +++++--------------
1 file changed, 5 insertions(+), 14 deletions(-)
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 81b354f..03d13d7 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -95,20 +95,9 @@ async def home(
resources = []
oidc_provider_settings = None
- if user is None:
- access_token_scope = None
- else:
- try:
- access_token_scope = user.get_scope(verify_signature=False)
- except InvalidTokenError as err:
- access_token_scope = None
- logger.info("Invalid token")
- logger.exception(err)
-
context = {
"settings": settings.model_dump(),
"user": user,
- "access_token_scope": access_token_scope,
"now": now,
"oidc_provider": oidc_provider,
"oidc_provider_settings": oidc_provider_settings,
@@ -124,13 +113,15 @@ async def home(
assert oidc_provider is not None
assert oidc_provider.name is not None
oidc_provider_settings = oidc_providers_settings[oidc_provider.name]
+ access_token_parsed = oidc_provider_settings.decode(
+ token["access_token"], verify_signature=False
+ )
+ context["access_token_scope"] = access_token_parsed["scope"]
# context["id_token_parsed"] = pretty_details(user, now)
context["id_token_parsed"] = oidc_provider_settings.decode(
token["id_token"], verify_signature=False
)
- context["access_token_parsed"] = oidc_provider_settings.decode(
- token["access_token"], verify_signature=False
- )
+ context["access_token_parsed"] = access_token_parsed
context["refresh_token_parsed"] = oidc_provider_settings.decode(
token["refresh_token"], verify_signature=False
)
From c5bb4f4319445ba145ab08067593a6957ce37f42 Mon Sep 17 00:00:00 2001
From: phil
Date: Sun, 9 Feb 2025 06:20:48 +0100
Subject: [PATCH 25/70] Refactor most code, isolate authlib somehow
---
src/oidc_test/auth_misc.py | 29 -------
src/oidc_test/auth_provider.py | 43 ++++++++++
src/oidc_test/auth_utils.py | 80 +++++++++++--------
src/oidc_test/database.py | 27 +++----
src/oidc_test/main.py | 128 ++++++++++++------------------
src/oidc_test/models.py | 10 +--
src/oidc_test/resource_server.py | 9 ++-
src/oidc_test/settings.py | 45 ++---------
src/oidc_test/templates/base.html | 2 +-
src/oidc_test/templates/home.html | 28 +++----
10 files changed, 183 insertions(+), 218 deletions(-)
delete mode 100644 src/oidc_test/auth_misc.py
create mode 100644 src/oidc_test/auth_provider.py
diff --git a/src/oidc_test/auth_misc.py b/src/oidc_test/auth_misc.py
deleted file mode 100644
index a4e9ea3..0000000
--- a/src/oidc_test/auth_misc.py
+++ /dev/null
@@ -1,29 +0,0 @@
-from datetime import datetime, timedelta
-from collections import OrderedDict
-
-from .models import User
-
-time_keys = set(("iat", "exp", "auth_time", "updated_at"))
-
-
-def pretty_details(user: User, now: datetime) -> OrderedDict:
- details = OrderedDict()
- # breakpoint()
- for key in sorted(time_keys):
- try:
- dt = datetime.fromtimestamp(user.userinfo[key])
- except (KeyError, TypeError):
- pass
- else:
- td = now - dt
- td = timedelta(days=td.days, seconds=td.seconds)
- if td.days < 0:
- ptd = f"in {-td} h:m:s"
- else:
- ptd = f"{td} h:m:s ago"
- details[key] = f"{user.userinfo[key]} - {dt} ({ptd})"
- for key in sorted(user.userinfo):
- if key in time_keys:
- continue
- details[key] = user.userinfo[key]
- return details
diff --git a/src/oidc_test/auth_provider.py b/src/oidc_test/auth_provider.py
new file mode 100644
index 0000000..bed4596
--- /dev/null
+++ b/src/oidc_test/auth_provider.py
@@ -0,0 +1,43 @@
+from typing import Any
+from jwt import decode
+import logging
+
+from authlib.integrations.starlette_client.apps import StarletteOAuth2App
+
+from .settings import AuthProviderSettings, settings
+
+logger = logging.getLogger("oidc-test")
+
+
+class Provider(AuthProviderSettings):
+ class Config:
+ arbitrary_types_allowed = True
+
+ authlib_client: StarletteOAuth2App = StarletteOAuth2App(None)
+
+ def decode(self, token: str, verify_signature: bool = True) -> dict[str, Any]:
+ """Decode the token with signature check"""
+ 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(),
+ algorithms=[self.signature_alg],
+ audience=["account", "oidc-test", "oidc-test-web"],
+ options={
+ "verify_signature": verify_signature,
+ }, # not settings.insecure.skip_verify_signature},
+ )
+
+
+providers: dict[str, Provider] = {}
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py
index cab14b2..e62fe39 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth_utils.py
@@ -1,4 +1,3 @@
-import re
from typing import Union, Annotated
from functools import wraps
import logging
@@ -14,7 +13,8 @@ from authlib.oauth2.auth import OAuth2Token
from .models import User
from .database import db, TokenNotInDb, UserNotInDB
-from .settings import oidc_providers_settings
+from .settings import settings
+from .auth_provider import providers, Provider
logger = logging.getLogger("oidc-test")
@@ -35,11 +35,13 @@ async def fetch_token(name, request):
# return token.to_token()
-async def update_token(name, token, refresh_token=None, access_token=None):
+async def update_token(
+ provider_id, token, refresh_token: str | None = None, access_token: str | None = None
+):
"""Update the token in the database"""
- oidc_provider_settings = oidc_providers_settings[name]
- sid: str = oidc_provider_settings.decode(token["id_token"])["sid"]
- item = await db.get_token(oidc_provider_settings, sid)
+ provider = providers[provider_id]
+ sid: str = provider.decode(token["id_token"])["sid"]
+ item = await db.get_token(provider, sid)
# update old token
item["access_token"] = token["access_token"]
item["refresh_token"] = token["refresh_token"]
@@ -54,10 +56,12 @@ authlib_oauth = OAuth(cache=None, fetch_token=fetch_token, update_token=update_t
def init_providers():
- # Add oidc providers to authlib from the settings
- for id, provider in oidc_providers_settings.items():
+ """Add oidc providers to authlib from the settings
+ and build the providers dict"""
+ for provider_settings in settings.auth.providers:
+ provider = Provider(**provider_settings.model_dump())
authlib_oauth.register(
- name=id,
+ name=provider.id,
server_metadata_url=provider.openid_configuration,
client_kwargs={
"scope": " ".join(
@@ -74,6 +78,8 @@ def init_providers():
update_token=update_token,
# client_id="some-client-id", # if enabled, authlib will also check that the access token belongs to this client id (audience)
)
+ provider.authlib_client = getattr(authlib_oauth, provider.id)
+ providers[provider.id] = provider
init_providers()
@@ -82,33 +88,41 @@ init_providers()
async def get_providers_info():
# Get the public key:
async with AsyncClient() as client:
- for provider_settings in oidc_providers_settings.values():
- if provider_settings.info_url:
- provider_info = await client.get(provider_settings.url)
- provider_settings.info = provider_info.json()
+ for provider in providers.values():
+ if provider.info_url:
+ provider_info = await client.get(provider.url)
+ provider.info = provider_info.json()
-def get_oidc_provider_or_none(request: Request) -> StarletteOAuth2App | None:
+def get_auth_provider_client_or_none(request: Request) -> StarletteOAuth2App | None:
"""Return the oidc_provider from a request object, from the session.
It can be used in Depends()"""
- if (oidc_provider_id := request.session.get("oidc_provider_id")) is None:
- return
- try:
- return getattr(authlib_oauth, str(oidc_provider_id))
- except AttributeError:
+ if (auth_provider_id := request.session.get("auth_provider_id")) is None:
return
+ return getattr(authlib_oauth, str(auth_provider_id), None)
-def get_oidc_provider(request: Request) -> StarletteOAuth2App:
- if (oidc_provider := get_oidc_provider_or_none(request)) is None:
- if oidc_provider is None:
- raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No provider")
- else:
- raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No such provider")
+def get_auth_provider_client(request: Request) -> StarletteOAuth2App:
+ if (oidc_provider := get_auth_provider_client_or_none(request)) is None:
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No such provider")
else:
return oidc_provider
+def get_auth_provider_or_none(request: Request) -> Provider | None:
+ """Return the oidc_provider settings from a request object, from the session.
+ It can be used in Depends()"""
+ if (auth_provider_id := request.session.get("auth_provider_id")) is None:
+ return
+ return providers.get(auth_provider_id)
+
+
+def get_auth_provider(request: Request) -> Provider:
+ if (provider := get_auth_provider_or_none(request)) is None:
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No such provider")
+ return provider
+
+
async def get_current_user(request: Request) -> User:
"""Get the current user from a request object.
Also validates the token expiration time.
@@ -120,11 +134,11 @@ async def get_current_user(request: Request) -> User:
user = await db.get_user(user_sub)
## Check if the token is expired
if token.is_expired():
- oidc_provider = get_oidc_provider(request=request)
+ provider = get_auth_provider(request=request)
## Ask a new refresh token from the provider
logger.info(f"Token expired for user {user.name}")
try:
- userinfo = await oidc_provider.fetch_access_token(
+ userinfo = await provider.authlib_client.fetch_access_token(
refresh_token=token.get("refresh_token")
)
assert userinfo is not None
@@ -150,14 +164,12 @@ async def get_token(request: Request) -> OAuth2Token:
"""Return the token from the session.
Can be used in Depends()"""
try:
- oidc_provider_settings = oidc_providers_settings[
- request.session.get("oidc_provider_id", "")
- ]
+ provider = providers[request.session.get("auth_provider_id", "")]
except KeyError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid provider")
try:
return await db.get_token(
- oidc_provider_settings,
+ provider,
request.session.get("sid"),
)
except (TokenNotInDb, InvalidKeyError, DecodeError) as err:
@@ -219,7 +231,7 @@ async def get_user_from_token(
"Request headers must have a 'auth_provider' field",
)
try:
- auth_provider_settings = oidc_providers_settings[auth_provider_id]
+ provider = providers[auth_provider_id]
except KeyError:
if auth_provider_id == "":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No auth provider")
@@ -230,7 +242,7 @@ async def get_user_from_token(
if token == "":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No token")
try:
- payload = auth_provider_settings.decode(token)
+ payload = provider.decode(token)
except ExpiredSignatureError as err:
logger.info(f"Expired signature: {err}")
raise HTTPException(
@@ -261,7 +273,7 @@ async def get_user_from_token(
user = await db.add_user(
sub=payload["sub"],
user_info=payload,
- oidc_provider=getattr(authlib_oauth, auth_provider_id),
+ auth_provider=getattr(authlib_oauth, auth_provider_id),
access_token=token,
)
return user
diff --git a/src/oidc_test/database.py b/src/oidc_test/database.py
index d3bdd4e..3493429 100644
--- a/src/oidc_test/database.py
+++ b/src/oidc_test/database.py
@@ -2,11 +2,10 @@
import logging
-from authlib.integrations.starlette_client.apps import StarletteOAuth2App
from authlib.oauth2.rfc6749 import OAuth2Token
-from .settings import OIDCProvider, oidc_providers_settings
from .models import User, Role
+from .auth_provider import Provider, providers
logger = logging.getLogger("oidc-test")
@@ -29,23 +28,23 @@ class Database:
self,
sub: str,
user_info: dict,
- oidc_provider: StarletteOAuth2App,
+ auth_provider: Provider,
access_token: str,
access_token_decoded: dict | None = None,
) -> User:
if access_token_decoded is None:
- assert oidc_provider.name is not None
- oidc_provider_settings = oidc_providers_settings[oidc_provider.name]
- access_token_decoded = oidc_provider_settings.decode(access_token)
+ assert auth_provider.name is not None
+ provider = providers[auth_provider.id]
+ access_token_decoded = provider.decode(access_token)
+ user_info["auth_provider_id"] = auth_provider.id
user = User(**user_info)
user.userinfo = user_info
- user.oidc_provider = oidc_provider
- user.access_token = access_token
- user.access_token_decoded = access_token_decoded
+ # user.access_token = access_token
+ # user.access_token_decoded = access_token_decoded
# Add roles provided in the access token
roles = set()
try:
- r = access_token_decoded["resource_access"][oidc_provider.client_id]["roles"]
+ r = access_token_decoded["resource_access"][auth_provider.client_id]["roles"]
roles.update(r)
except KeyError:
pass
@@ -66,19 +65,19 @@ class Database:
raise UserNotInDB
return self.users[sub]
- async def add_token(self, oidc_provider_settings: OIDCProvider, token: OAuth2Token) -> None:
+ async def add_token(self, provider: Provider, token: OAuth2Token) -> None:
"""Store a token using as key the sid (auth provider's session id)
in the id_token"""
- assert isinstance(oidc_provider_settings, OIDCProvider)
+ assert isinstance(provider, Provider)
sid = token["userinfo"]["sid"]
self.tokens[sid] = token
async def get_token(
self,
- oidc_provider_settings: OIDCProvider,
+ provider: Provider,
sid: str | None,
) -> OAuth2Token:
- assert isinstance(oidc_provider_settings, OIDCProvider)
+ assert isinstance(provider, Provider)
if sid is None:
raise TokenNotInDb
try:
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 03d13d7..304df92 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -15,7 +15,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
-from jwt import InvalidTokenError, PyJWTError
+from jwt import PyJWTError
from starlette.middleware.sessions import SessionMiddleware
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
from authlib.integrations.base_client import OAuthError
@@ -26,11 +26,12 @@ from authlib.oauth2.rfc6749 import OAuth2Token
# from fastapi.security import OpenIdConnect
# from pkce import generate_code_verifier, generate_pkce_pair
-from .settings import settings, oidc_providers_settings
+from .settings import settings
+from .auth_provider import Provider, providers
from .models import User
from .auth_utils import (
- get_oidc_provider,
- get_oidc_provider_or_none,
+ get_auth_provider,
+ get_auth_provider_or_none,
get_current_user_or_none,
authlib_oauth,
get_providers_info,
@@ -38,7 +39,6 @@ from .auth_utils import (
get_token,
update_token,
)
-from .auth_misc import pretty_details
from .database import TokenNotInDb, db
from .resource_server import resource_server
@@ -78,51 +78,30 @@ app.mount("/resource", resource_server, name="resource_server")
async def home(
request: Request,
user: Annotated[User, Depends(get_current_user_or_none)],
- oidc_provider: Annotated[StarletteOAuth2App | None, Depends(get_oidc_provider_or_none)],
+ provider: Annotated[Provider | None, Depends(get_auth_provider_or_none)],
token: Annotated[OAuth2Token | None, Depends(get_token_or_none)],
) -> HTMLResponse:
- now = datetime.now()
- if oidc_provider and (
- (
- oidc_provider_settings := oidc_providers_settings.get(
- request.session.get("oidc_provider_id", "")
- )
- )
- is not None
- ):
- resources = oidc_provider_settings.resources
- else:
- resources = []
- oidc_provider_settings = None
-
context = {
"settings": settings.model_dump(),
"user": user,
- "now": now,
- "oidc_provider": oidc_provider,
- "oidc_provider_settings": oidc_provider_settings,
- "resources": resources,
+ "now": datetime.now(),
+ "auth_provider": provider,
}
- if token is None:
+ if provider is None or token is None:
context["access_token"] = None
context["id_token_parsed"] = None
context["access_token_parsed"] = None
context["refresh_token_parsed"] = None
+ context["resources"] = None
else:
context["access_token"] = token["access_token"]
- assert oidc_provider is not None
- assert oidc_provider.name is not None
- oidc_provider_settings = oidc_providers_settings[oidc_provider.name]
- access_token_parsed = oidc_provider_settings.decode(
- token["access_token"], verify_signature=False
- )
+ context["resources"] = provider.resources
+ access_token_parsed = provider.decode(token["access_token"], verify_signature=False)
context["access_token_scope"] = access_token_parsed["scope"]
# context["id_token_parsed"] = pretty_details(user, now)
- context["id_token_parsed"] = oidc_provider_settings.decode(
- token["id_token"], verify_signature=False
- )
+ context["id_token_parsed"] = provider.decode(token["id_token"], verify_signature=False)
context["access_token_parsed"] = access_token_parsed
- context["refresh_token_parsed"] = oidc_provider_settings.decode(
+ context["refresh_token_parsed"] = provider.decode(
token["refresh_token"], verify_signature=False
)
return templates.TemplateResponse(name="home.html", request=request, context=context)
@@ -131,20 +110,20 @@ async def home(
# Endpoints for the login / authorization process
-@app.get("/login/{oidc_provider_id}")
-async def login(request: Request, oidc_provider_id: str) -> RedirectResponse:
+@app.get("/login/{auth_provider_id}")
+async def login(request: Request, auth_provider_id: str) -> RedirectResponse:
"""Login with the provider id, giving the browser a redirect to its authorize page.
- The provider is expected to send the browser back to our own /auth/{oidc_provider_id} url
+ The provider is expected to send the browser back to our own /auth/{auth_provider_id} url
with the token.
"""
- redirect_uri = request.url_for("auth", oidc_provider_id=oidc_provider_id)
+ redirect_uri = request.url_for("auth", auth_provider_id=auth_provider_id)
try:
- provider: StarletteOAuth2App = getattr(authlib_oauth, oidc_provider_id)
+ provider: StarletteOAuth2App = getattr(authlib_oauth, auth_provider_id)
except AttributeError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No such provider")
# if (
- # code_challenge_method := oidc_providers_settings[
- # oidc_provider_id
+ # code_challenge_method := providers[
+ # auth_provider_id
# ].code_challenge_method
# ) is not None:
# #client = AsyncOAuth2Client(..., code_challenge_method=code_challenge_method)
@@ -164,30 +143,30 @@ async def login(request: Request, oidc_provider_id: str) -> RedirectResponse:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Cannot reach provider")
-@app.get("/auth/{oidc_provider_id}")
-async def auth(request: Request, oidc_provider_id: str) -> RedirectResponse:
+@app.get("/auth/{auth_provider_id}")
+async def auth(request: Request, auth_provider_id: str) -> RedirectResponse:
"""Decrypt the auth token, store it to the session (cookie based)
and response to the browser with a redirect to a "welcome user" page.
"""
try:
- oidc_provider: StarletteOAuth2App = getattr(authlib_oauth, oidc_provider_id)
+ authlib_client: StarletteOAuth2App = getattr(authlib_oauth, auth_provider_id)
except AttributeError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No such provider")
try:
- token: OAuth2Token = await oidc_provider.authorize_access_token(request)
+ token: OAuth2Token = await authlib_client.authorize_access_token(request)
except OAuthError as error:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=error.error)
- # Remember the oidc_provider in the session
+ # Remember the authlib_client in the session
# logger.info(f"Scope: {token['scope']}")
- request.session["oidc_provider_id"] = oidc_provider_id
+ request.session["auth_provider_id"] = auth_provider_id
#
# One could process the full decoded token which contains extra information
# eg for updates. Here we are only interested in roles
#
if userinfo := token.get("userinfo"):
- # Remember the oidc_provider in the session
- request.session["oidc_provider_id"] = oidc_provider_id
- # User id (sub) given by oidc provider
+ # Remember the authlib_client in the session
+ request.session["auth_provider_id"] = auth_provider_id
+ # User id (sub) given by auth provider
sub = userinfo["sub"]
# Build and remember the user in the session
request.session["user_sub"] = sub
@@ -196,7 +175,7 @@ async def auth(request: Request, oidc_provider_id: str) -> RedirectResponse:
user = await db.add_user(
sub,
user_info=userinfo,
- oidc_provider=oidc_provider,
+ auth_provider=providers[auth_provider_id],
access_token=token["access_token"],
)
except PyJWTError as err:
@@ -208,48 +187,41 @@ async def auth(request: Request, oidc_provider_id: str) -> RedirectResponse:
# Add the provider session id to the session
request.session["sid"] = userinfo["sid"]
# Add the token to the db because it is used for logout
- assert oidc_provider.name is not None
- oidc_provider_settings = oidc_providers_settings[oidc_provider.name]
- await db.add_token(oidc_provider_settings, token)
+ provider = providers[auth_provider_id]
+ await db.add_token(provider, token)
# Send the user to the home: (s)he is authenticated
return RedirectResponse(url=request.url_for("home"))
else:
# Not sure if it's correct to redirect to plain login
# if no userinfo is provided
- return RedirectResponse(url=request.url_for("login", oidc_provider_id=oidc_provider_id))
+ return RedirectResponse(url=request.url_for("login", auth_provider_id=auth_provider_id))
@app.get("/account")
async def account(
- request: Request,
+ provider: Annotated[Provider, Depends(get_auth_provider)],
) -> RedirectResponse:
- if (
- oidc_provider_settings := oidc_providers_settings.get(
- request.session.get("oidc_provider_id", "")
- )
- ) is None:
- raise HTTPException(status.HTTP_406_NOT_ACCEPTABLE, detail="No oidc provider settings")
- return RedirectResponse(f"{oidc_provider_settings.account_url_template}")
+ """Redirect to the auth provider account management,
+ if account_url_template is in the provider's settings"""
+ return RedirectResponse(f"{provider.account_url_template}")
@app.get("/logout")
async def logout(
request: Request,
- oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
+ provider: Annotated[Provider, Depends(get_auth_provider)],
) -> RedirectResponse:
# Clear session
request.session.pop("user_sub", None)
# Get provider's endpoint
- if (provider_logout_uri := oidc_provider.server_metadata.get("end_session_endpoint")) is None:
- logger.warn(f"Cannot find end_session_endpoint for provider {oidc_provider.name}")
+ if (
+ provider_logout_uri := provider.authlib_client.server_metadata.get("end_session_endpoint")
+ ) is None:
+ logger.warn(f"Cannot find end_session_endpoint for provider {provider.name}")
return RedirectResponse(request.url_for("non_compliant_logout"))
post_logout_uri = request.url_for("home")
- oidc_provider_settings = oidc_providers_settings.get(
- request.session.get("oidc_provider_id", "")
- )
- assert oidc_provider_settings is not None
try:
- token = await db.get_token(oidc_provider_settings, request.session.pop("sid", None))
+ token = await db.get_token(provider, request.session.pop("sid", None))
except TokenNotInDb:
logger.warn("No session in db for the token or no token")
return RedirectResponse(request.url_for("home"))
@@ -270,30 +242,30 @@ async def logout(
@app.get("/non-compliant-logout")
async def non_compliant_logout(
request: Request,
- oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
+ provider: Annotated[StarletteOAuth2App, Depends(get_auth_provider)],
):
"""A page for non-compliant OAuth2 servers that we cannot log out."""
# Clear the remain of the session
- request.session.pop("oidc_provider_id", None)
+ request.session.pop("auth_provider_id", None)
return templates.TemplateResponse(
name="non_compliant_logout.html",
request=request,
- context={"oidc_provider": oidc_provider, "home_url": request.url_for("home")},
+ context={"oidc_provider": provider, "home_url": request.url_for("home")},
)
@app.get("/refresh")
async def refresh(
request: Request,
- oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
+ provider: Annotated[Provider, Depends(get_auth_provider)],
token: Annotated[OAuth2Token, Depends(get_token)],
) -> RedirectResponse:
"""Manually refresh token"""
- new_token = await oidc_provider.fetch_access_token(
+ new_token = await provider.authlib_client.fetch_access_token(
refresh_token=token["refresh_token"],
grant_type="refresh_token",
)
- await update_token(oidc_provider.name, new_token)
+ await update_token(provider.id, new_token)
return RedirectResponse(url=request.url_for("home"))
diff --git a/src/oidc_test/models.py b/src/oidc_test/models.py
index 8aee2e6..eda63a6 100644
--- a/src/oidc_test/models.py
+++ b/src/oidc_test/models.py
@@ -8,7 +8,6 @@ from pydantic import (
EmailStr,
ConfigDict,
)
-from authlib.integrations.starlette_client.apps import StarletteOAuth2App
from sqlmodel import SQLModel, Field
logger = logging.getLogger("oidc-test")
@@ -37,7 +36,7 @@ class User(UserBase):
userinfo: dict = {}
access_token: str | None = None
access_token_decoded: dict[str, Any] | None = None
- oidc_provider: StarletteOAuth2App | None = None
+ auth_provider_id: str
@computed_field
@cached_property
@@ -56,11 +55,10 @@ class User(UserBase):
def decode_access_token(self, verify_signature: bool = True):
assert self.access_token is not None
- assert self.oidc_provider is not None
- assert self.oidc_provider.name is not None
- from .settings import oidc_providers_settings
+ assert self.auth_provider_id is not None
+ from .auth_provider import providers
- return oidc_providers_settings[self.oidc_provider.name].decode(
+ return providers[self.auth_provider_id].decode(
self.access_token, verify_signature=verify_signature
)
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index cb944ed..e5670ed 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -2,6 +2,7 @@ from datetime import datetime
from typing import Annotated
import logging
+from authlib.jose import Key
from httpx import AsyncClient
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from fastapi import FastAPI, HTTPException, Depends, status
@@ -15,10 +16,9 @@ from .models import User
from .auth_utils import (
get_user_from_token,
UserWithRole,
- # get_oidc_provider,
- # get_token,
)
from .settings import settings
+from .auth_provider import providers
logger = logging.getLogger("oidc-test")
@@ -128,7 +128,10 @@ async def get_resource(resource_id: str, user: User) -> dict:
"""
Resource processing: build an informative rely as a simple showcase
"""
- pname = getattr(user.oidc_provider, "name", "?")
+ try:
+ pname = providers[user.auth_provider_id].name
+ except KeyError:
+ pname = "?"
resp = {
"hello": f"Hi {user.name} from an OAuth resource provider",
"comment": f"I received a request for '{resource_id}' "
diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py
index e448c1e..9a789a0 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -1,11 +1,9 @@
from os import environ
import string
import random
-from typing import Type, Tuple, Any
+from typing import Type, Tuple
from pathlib import Path
-import logging
-from jwt import decode
from pydantic import BaseModel, computed_field, AnyUrl
from pydantic_settings import (
BaseSettings,
@@ -17,8 +15,6 @@ from starlette.requests import Request
from .models import User
-logger = logging.getLogger("oidc-test")
-
class Resource(BaseModel):
"""A resource with an URL that can be accessed with an OAuth2 access token"""
@@ -27,8 +23,8 @@ class Resource(BaseModel):
name: str
-class OIDCProvider(BaseModel):
- """OIDC provider, can also be a resource server"""
+class AuthProviderSettings(BaseModel):
+ """Auth provider, can also be a resource server"""
id: str
name: str
@@ -79,30 +75,6 @@ class OIDCProvider(BaseModel):
-----END PUBLIC KEY-----
"""
- def decode(self, token: str, verify_signature: bool = True) -> dict[str, Any]:
- """Decode the token with signature check"""
- 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(),
- algorithms=[self.signature_alg],
- audience=["account", "oidc-test", "oidc-test-web"],
- options={
- "verify_signature": verify_signature,
- }, # not settings.insecure.skip_verify_signature},
- )
-
class ResourceProvider(BaseModel):
id: str
@@ -111,9 +83,9 @@ class ResourceProvider(BaseModel):
resources: list[Resource] = []
-class OIDCSettings(BaseModel):
+class AuthSettings(BaseModel):
show_session_details: bool = False
- providers: list[OIDCProvider] = []
+ providers: list[AuthProviderSettings] = []
swagger_provider: str = ""
@@ -128,7 +100,7 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_nested_delimiter="__")
- oidc: OIDCSettings = OIDCSettings()
+ auth: AuthSettings = AuthSettings()
resource_providers: list[ResourceProvider] = []
secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16))
log: bool = False
@@ -163,8 +135,3 @@ class Settings(BaseSettings):
settings = Settings()
-
-
-oidc_providers_settings: dict[str, OIDCProvider] = dict(
- [(provider.id, provider) for provider in settings.oidc.providers]
-)
diff --git a/src/oidc_test/templates/base.html b/src/oidc_test/templates/base.html
index 2ce758c..4cb56f5 100644
--- a/src/oidc_test/templates/base.html
+++ b/src/oidc_test/templates/base.html
@@ -4,7 +4,7 @@
-
+
OIDC-test - FastAPI client
{% block content %}
{% endblock %}
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index 08bcf43..7275f2d 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -8,7 +8,7 @@
Log in with:
- {% for provider in settings.oidc.providers %}
+ {% for provider in settings.auth.providers %}
{{ provider.name }}
@@ -32,7 +32,7 @@
{{ user.email }}
Provider:
- {{ oidc_provider_settings.name }}
+ {{ auth_provider.name }}
{% if user.roles %}
@@ -50,9 +50,9 @@
{% endfor %}
{% endif %}
- {% if oidc_provider_settings.account_url_template %}
+ {% if auth_provider.account_url_template %}
Account management
@@ -67,21 +67,21 @@
Resources validated by scope:
- Time
- BS
+ Time
+ BS
Resources validated by role:
- Public
- Auth protected content
- Auth + foorole protected content
- Auth + foorole or barrole protected content
- Auth + barrole protected content
- Auth + foorole and barrole protected content
- Using FastAPI Depends
-
+ Public
+ Auth protected content
+ Auth + foorole protected content
+ Auth + foorole or barrole protected content
+ Auth + barrole protected content
+ Auth + foorole and barrole protected content
+ Using FastAPI Depends
+
From 496ce016e3d31fdb003146a3f84bc86726275e6d Mon Sep 17 00:00:00 2001
From: phil
Date: Mon, 10 Feb 2025 02:05:34 +0100
Subject: [PATCH 26/70] Continue refactor; fetch resources from the providers'
settings
---
src/oidc_test/auth_provider.py | 64 +++++++++++++++--
src/oidc_test/auth_utils.py | 45 ++++++------
src/oidc_test/database.py | 9 ++-
src/oidc_test/main.py | 68 +++++++++++++------
src/oidc_test/models.py | 6 +-
src/oidc_test/resource_server.py | 50 +++++++++++---
src/oidc_test/settings.py | 16 ++---
src/oidc_test/static/utils.js | 17 +++--
src/oidc_test/templates/home.html | 49 +++++++------
.../templates/non_compliant_logout.html | 6 +-
10 files changed, 217 insertions(+), 113 deletions(-)
diff --git a/src/oidc_test/auth_provider.py b/src/oidc_test/auth_provider.py
index bed4596..e50241c 100644
--- a/src/oidc_test/auth_provider.py
+++ b/src/oidc_test/auth_provider.py
@@ -1,26 +1,41 @@
+from json import JSONDecodeError
from typing import Any
from jwt import decode
import logging
+from collections import OrderedDict
+from pydantic import ConfigDict
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
+from httpx import AsyncClient
from .settings import AuthProviderSettings, settings
+from .models import User
logger = logging.getLogger("oidc-test")
+class NoPublicKey(Exception):
+ pass
+
+
class Provider(AuthProviderSettings):
- class Config:
- arbitrary_types_allowed = True
+ # To allow authlib_client as StarletteOAuth2App
+ model_config = ConfigDict(arbitrary_types_allowed=True) # type:ignore
authlib_client: StarletteOAuth2App = StarletteOAuth2App(None)
+ info: dict[str, Any] = {}
+ unknown_auth_user: User
- def decode(self, token: str, verify_signature: bool = True) -> dict[str, Any]:
+ def decode(self, token: str, verify_signature: bool | None = None) -> dict[str, Any]:
"""Decode the token with signature check"""
+ if self.public_key is None:
+ raise NoPublicKey
+ if verify_signature is None:
+ verify_signature = self.skip_verify_signature
if settings.debug_token:
decoded = decode(
token,
- self.get_public_key(),
+ self.public_key,
algorithms=[self.signature_alg],
audience=["account", "oidc-test", "oidc-test-web"],
options={
@@ -31,7 +46,7 @@ class Provider(AuthProviderSettings):
logger.debug(str(decoded))
return decode(
token,
- self.get_public_key(),
+ self.public_key,
algorithms=[self.signature_alg],
audience=["account", "oidc-test", "oidc-test-web"],
options={
@@ -39,5 +54,42 @@ class Provider(AuthProviderSettings):
}, # not settings.insecure.skip_verify_signature},
)
+ async def get_info(self):
+ # Get the public key:
+ async with AsyncClient() as client:
+ public_key: str | None = None
+ if self.info_url is not None:
+ try:
+ provider_info = await client.get(self.info_url)
+ except Exception:
+ raise NoPublicKey
+ try:
+ self.info = provider_info.json()
+ except JSONDecodeError:
+ raise NoPublicKey
+ if "public_key" in self.info:
+ # For Keycloak
+ try:
+ public_key = str(self.info["public_key"])
+ except KeyError:
+ raise NoPublicKey
+ elif "keys" in self.info:
+ # For Forgejo/Gitea
+ try:
+ public_key = str(self.info["keys"][0]["n"])
+ except KeyError:
+ raise NoPublicKey
+ if self.public_key_url is not None:
+ resp = await client.get(self.public_key_url)
+ public_key = resp.text
+ if public_key is None:
+ raise NoPublicKey
+ self.public_key = "\n".join(
+ ["-----BEGIN PUBLIC KEY-----", public_key, "-----END PUBLIC KEY-----"]
+ )
-providers: dict[str, Provider] = {}
+ def get_session_key(self, userinfo):
+ return userinfo[self.session_key]
+
+
+providers: OrderedDict[str, Provider] = OrderedDict()
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth_utils.py
index e62fe39..8cd5028 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth_utils.py
@@ -5,8 +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, DecodeError
-from httpx import AsyncClient
+from jwt import ExpiredSignatureError, InvalidKeyError, DecodeError, PyJWTError
# from authlib.oauth1.auth import OAuthToken
from authlib.oauth2.auth import OAuth2Token
@@ -40,7 +39,7 @@ async def update_token(
):
"""Update the token in the database"""
provider = providers[provider_id]
- sid: str = provider.decode(token["id_token"])["sid"]
+ sid: str = provider.get_session_key(provider.decode(token["id_token"]))
item = await db.get_token(provider, sid)
# update old token
item["access_token"] = token["access_token"]
@@ -59,7 +58,12 @@ def init_providers():
"""Add oidc providers to authlib from the settings
and build the providers dict"""
for provider_settings in settings.auth.providers:
- provider = Provider(**provider_settings.model_dump())
+ provider_settings_dict = provider_settings.model_dump()
+ # Add an anonymous user, that cannot be identified but has provided a valid access token
+ provider_settings_dict["unknown_auth_user"] = User(
+ sub="", auth_provider_id=provider_settings.id
+ )
+ provider = Provider(**provider_settings_dict)
authlib_oauth.register(
name=provider.id,
server_metadata_url=provider.openid_configuration,
@@ -85,15 +89,6 @@ def init_providers():
init_providers()
-async def get_providers_info():
- # Get the public key:
- async with AsyncClient() as client:
- for provider in providers.values():
- if provider.info_url:
- provider_info = await client.get(provider.url)
- provider.info = provider_info.json()
-
-
def get_auth_provider_client_or_none(request: Request) -> StarletteOAuth2App | None:
"""Return the oidc_provider from a request object, from the session.
It can be used in Depends()"""
@@ -166,6 +161,8 @@ async def get_token(request: Request) -> OAuth2Token:
try:
provider = providers[request.session.get("auth_provider_id", "")]
except KeyError:
+ request.session.pop("auth_provider_id", None)
+ request.session.pop("user_sub", None)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid provider")
try:
return await db.get_token(
@@ -239,29 +236,27 @@ async def get_user_from_token(
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, f"Unknown auth provider '{auth_provider_id}'"
)
- if token == "":
+ if token == "None":
+ request.session.pop("auth_provider_id", None)
+ request.session.pop("user_sub", None)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No token")
try:
payload = provider.decode(token)
- except ExpiredSignatureError as err:
- logger.info(f"Expired signature: {err}")
+ except ExpiredSignatureError:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Expired signature (refresh not implemented yet)",
)
- except InvalidKeyError as err:
- logger.info(f"Invalid key: {err}")
+ except InvalidKeyError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid auth provider key")
- except Exception as err:
- logger.info("Cannot decode token, see below")
- logger.exception(err)
- raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Cannot decode token")
+ except PyJWTError as err:
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED, f"Cannot decode token: {err.__class__.__name__}"
+ )
try:
user_id = payload["sub"]
except KeyError:
- raise HTTPException(
- status.HTTP_401_UNAUTHORIZED, "Wrong token: 'sub' (user id) not found"
- )
+ return provider.unknown_auth_user
try:
user = await db.get_user(user_id)
if user.access_token != token:
diff --git a/src/oidc_test/database.py b/src/oidc_test/database.py
index 3493429..659fd13 100644
--- a/src/oidc_test/database.py
+++ b/src/oidc_test/database.py
@@ -3,6 +3,7 @@
import logging
from authlib.oauth2.rfc6749 import OAuth2Token
+from jwt import PyJWTError
from .models import User, Role
from .auth_provider import Provider, providers
@@ -35,7 +36,10 @@ class Database:
if access_token_decoded is None:
assert auth_provider.name is not None
provider = providers[auth_provider.id]
- access_token_decoded = provider.decode(access_token)
+ try:
+ access_token_decoded = provider.decode(access_token)
+ except PyJWTError:
+ access_token_decoded = {}
user_info["auth_provider_id"] = auth_provider.id
user = User(**user_info)
user.userinfo = user_info
@@ -68,8 +72,7 @@ class Database:
async def add_token(self, provider: Provider, token: OAuth2Token) -> None:
"""Store a token using as key the sid (auth provider's session id)
in the id_token"""
- assert isinstance(provider, Provider)
- sid = token["userinfo"]["sid"]
+ sid = provider.get_session_key(token["userinfo"])
self.tokens[sid] = token
async def get_token(
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 304df92..4018997 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -27,14 +27,13 @@ from authlib.oauth2.rfc6749 import OAuth2Token
# from pkce import generate_code_verifier, generate_pkce_pair
from .settings import settings
-from .auth_provider import Provider, providers
+from .auth_provider import NoPublicKey, Provider, providers
from .models import User
from .auth_utils import (
get_auth_provider,
get_auth_provider_or_none,
get_current_user_or_none,
authlib_oauth,
- get_providers_info,
get_token_or_none,
get_token,
update_token,
@@ -50,7 +49,12 @@ templates = Jinja2Templates(Path(__file__).parent / "templates")
@asynccontextmanager
async def lifespan(app: FastAPI):
assert app is not None
- await get_providers_info()
+ for provider in list(providers.values()):
+ try:
+ await provider.get_info()
+ except NoPublicKey:
+ logger.warn(f"Disable {provider.id}: public key not found")
+ del providers[provider.id]
yield
@@ -82,12 +86,13 @@ async def home(
token: Annotated[OAuth2Token | None, Depends(get_token_or_none)],
) -> HTMLResponse:
context = {
- "settings": settings.model_dump(),
+ "show_token": settings.show_token,
"user": user,
"now": datetime.now(),
"auth_provider": provider,
}
if provider is None or token is None:
+ context["providers"] = providers
context["access_token"] = None
context["id_token_parsed"] = None
context["access_token_parsed"] = None
@@ -96,14 +101,23 @@ async def home(
else:
context["access_token"] = token["access_token"]
context["resources"] = provider.resources
- access_token_parsed = provider.decode(token["access_token"], verify_signature=False)
- context["access_token_scope"] = access_token_parsed["scope"]
+ try:
+ access_token_parsed = provider.decode(token["access_token"], verify_signature=False)
+ except PyJWTError as err:
+ access_token_parsed = {"Cannot parse": err.__class__.__name__}
+ try:
+ context["access_token_scope"] = access_token_parsed["scope"]
+ except KeyError:
+ context["access_token_scope"] = None
# context["id_token_parsed"] = pretty_details(user, now)
context["id_token_parsed"] = provider.decode(token["id_token"], verify_signature=False)
context["access_token_parsed"] = access_token_parsed
- context["refresh_token_parsed"] = provider.decode(
- token["refresh_token"], verify_signature=False
- )
+ try:
+ context["refresh_token_parsed"] = provider.decode(
+ token["refresh_token"], verify_signature=False
+ )
+ except PyJWTError as err:
+ context["refresh_token_parsed"] = {"Cannot parse": err.__class__.__name__}
return templates.TemplateResponse(name="home.html", request=request, context=context)
@@ -144,16 +158,19 @@ async def login(request: Request, auth_provider_id: str) -> RedirectResponse:
@app.get("/auth/{auth_provider_id}")
-async def auth(request: Request, auth_provider_id: str) -> RedirectResponse:
+async def auth(
+ request: Request,
+ auth_provider_id: str,
+) -> RedirectResponse:
"""Decrypt the auth token, store it to the session (cookie based)
and response to the browser with a redirect to a "welcome user" page.
"""
try:
- authlib_client: StarletteOAuth2App = getattr(authlib_oauth, auth_provider_id)
- except AttributeError:
+ provider = providers[auth_provider_id]
+ except KeyError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No such provider")
try:
- token: OAuth2Token = await authlib_client.authorize_access_token(request)
+ token: OAuth2Token = await provider.authlib_client.authorize_access_token(request)
except OAuthError as error:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=error.error)
# Remember the authlib_client in the session
@@ -168,6 +185,14 @@ async def auth(request: Request, auth_provider_id: str) -> RedirectResponse:
request.session["auth_provider_id"] = auth_provider_id
# User id (sub) given by auth provider
sub = userinfo["sub"]
+ ## Get additional data from userinfo endpoint
+ # try:
+ # user_info_from_endpoint = await authlib_client.userinfo(
+ # token=token, follow_redirects=True
+ # )
+ # except Exception as err:
+ # logger.warn(f"Cannot get userinfo from endpoint: {err}")
+ # user_info_from_endpoint = {}
# Build and remember the user in the session
request.session["user_sub"] = sub
# Store the user in the database, which also verifies the token validity and signature
@@ -185,9 +210,8 @@ async def auth(request: Request, auth_provider_id: str) -> RedirectResponse:
)
assert isinstance(user, User)
# Add the provider session id to the session
- request.session["sid"] = userinfo["sid"]
+ request.session["sid"] = provider.get_session_key(userinfo)
# Add the token to the db because it is used for logout
- provider = providers[auth_provider_id]
await db.add_token(provider, token)
# Send the user to the home: (s)he is authenticated
return RedirectResponse(url=request.url_for("home"))
@@ -211,15 +235,16 @@ async def logout(
request: Request,
provider: Annotated[Provider, Depends(get_auth_provider)],
) -> RedirectResponse:
- # Clear session
- request.session.pop("user_sub", None)
# Get provider's endpoint
if (
provider_logout_uri := provider.authlib_client.server_metadata.get("end_session_endpoint")
) is None:
- logger.warn(f"Cannot find end_session_endpoint for provider {provider.name}")
+ logger.warn(f"Cannot find end_session_endpoint for provider {provider.id}")
return RedirectResponse(request.url_for("non_compliant_logout"))
post_logout_uri = request.url_for("home")
+ # Clear session
+ request.session.pop("user_sub", None)
+ request.session.pop("auth_provider_id", None)
try:
token = await db.get_token(provider, request.session.pop("sid", None))
except TokenNotInDb:
@@ -242,15 +267,16 @@ async def logout(
@app.get("/non-compliant-logout")
async def non_compliant_logout(
request: Request,
- provider: Annotated[StarletteOAuth2App, Depends(get_auth_provider)],
+ provider: Annotated[Provider, Depends(get_auth_provider)],
):
"""A page for non-compliant OAuth2 servers that we cannot log out."""
- # Clear the remain of the session
+ # Clear session
+ request.session.pop("user_sub", None)
request.session.pop("auth_provider_id", None)
return templates.TemplateResponse(
name="non_compliant_logout.html",
request=request,
- context={"oidc_provider": provider, "home_url": request.url_for("home")},
+ context={"auth_provider": provider, "home_url": request.url_for("home")},
)
diff --git a/src/oidc_test/models.py b/src/oidc_test/models.py
index eda63a6..7c5250b 100644
--- a/src/oidc_test/models.py
+++ b/src/oidc_test/models.py
@@ -49,13 +49,13 @@ class User(UserBase):
try:
access_token_scopes = self.decode_access_token().get("scope", "").split(" ")
except Exception as err:
- logger.info(f"Access token cannot be decoded: {err}")
+ logger.debug(f"Cannot find scope because the access token cannot be decoded: {err}")
access_token_scopes = []
return scope in set(info_scopes + access_token_scopes)
def decode_access_token(self, verify_signature: bool = True):
- assert self.access_token is not None
- assert self.auth_provider_id is not None
+ assert self.access_token is not None, "no access_token"
+ assert self.auth_provider_id is not None, "no auth_provider_id"
from .auth_provider import providers
return providers[self.auth_provider_id].decode(
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index e5670ed..f7f0433 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -2,7 +2,7 @@ from datetime import datetime
from typing import Annotated
import logging
-from authlib.jose import Key
+from authlib.oauth2.auth import OAuth2Token
from httpx import AsyncClient
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from fastapi import FastAPI, HTTPException, Depends, status
@@ -14,11 +14,12 @@ from fastapi.middleware.cors import CORSMiddleware
from .models import User
from .auth_utils import (
+ get_token_or_none,
get_user_from_token,
UserWithRole,
)
from .settings import settings
-from .auth_provider import providers
+from .auth_provider import providers, Provider
logger = logging.getLogger("oidc-test")
@@ -113,23 +114,51 @@ async def get_protected_by_foorole_or_barrole(
@resource_server.get("/{id}")
-async def get_resource_(
+async def get_resource(
id: str,
- # user: Annotated[User, Depends(get_current_user)],
- # oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
- # token: Annotated[OAuth2Token, Depends(get_token)],
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"""
- return await get_resource(id, user)
+ 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_resource(resource_id: str, user: User) -> dict:
+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 = providers[user.auth_provider_id].name
+ pname = provider.name
except KeyError:
pname = "?"
resp = {
@@ -164,7 +193,6 @@ async def process(user, resource_id, resp):
Too simple to be serious.
It's a good fit for a plugin architecture for production
"""
- assert user is not None
if resource_id == "time":
resp["time"] = datetime.now().strftime("%c")
elif resource_id == "bs":
diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py
index 9a789a0..86a2b6b 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -21,6 +21,7 @@ class Resource(BaseModel):
id: str
name: str
+ url: str
class AuthProviderSettings(BaseModel):
@@ -39,10 +40,12 @@ class AuthProviderSettings(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
public_key: str | None = None
+ public_key_url: str | None = None
signature_alg: str = "RS256"
resource_provider_scopes: list[str] = []
+ session_key: str = "sid"
+ skip_verify_signature: bool = True
@computed_field
@property
@@ -64,17 +67,6 @@ class AuthProviderSettings(BaseModel):
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"])
- if public_key is None:
- raise AttributeError(f"Cannot get public key for {self.name}")
- return f"""
- -----BEGIN PUBLIC KEY-----
- {public_key}
- -----END PUBLIC KEY-----
- """
-
class ResourceProvider(BaseModel):
id: str
diff --git a/src/oidc_test/static/utils.js b/src/oidc_test/static/utils.js
index 8e8ad59..6c9fae4 100644
--- a/src/oidc_test/static/utils.js
+++ b/src/oidc_test/static/utils.js
@@ -55,15 +55,24 @@ async function get_resource(id, token, authProvider) {
msg.innerHTML = ""
resourceElem.innerHTML = ""
Object.entries(resource).forEach(
- ([k, v]) => {
+ ([key, value]) => {
let r = document.createElement('div')
let kElem = document.createElement('div')
- kElem.innerText = k
+ kElem.innerText = key
kElem.className = "key"
let vElem = document.createElement('div')
- vElem.innerText = v
+ if (typeof value == "object") {
+ Object.entries(value).forEach(v => {
+ const ne = document.createElement('div')
+ ne.innerHTML = `${v[0]} : ${v[1]} `
+ vElem.appendChild(ne)
+ })
+ }
+ else {
+ vElem.innerText = value
+ }
vElem.className = "value"
- if (k == "sorry") {
+ if (key == "sorry") {
vElem.classList.add("error")
}
r.appendChild(kElem)
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index 7275f2d..da513c9 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -5,25 +5,24 @@
with OpenID Connect and OAuth2 with different providers.
{% if not user %}
-
-
Log in with:
-
- {% for provider in settings.auth.providers %}
-
-
- {{ provider.name }}
-
- {{ provider.hint }}
-
-
- {% else %}
- There is no authentication provider defined.
- Hint: check the settings.yaml file.
- {% endfor %}
-
-
- {% endif %}
- {% if user %}
+
+
Log in with:
+
+ {% for provider in providers.values() %}
+
+
+ {{ provider.name }}
+
+ {{ provider.hint }}
+
+
+ {% else %}
+ There is no authentication provider defined.
+ Hint: check the settings.yaml file.
+ {% endfor %}
+
+
+ {% else %}
Hey, {{ user.name }}
{% if user.picture %}
@@ -83,22 +82,22 @@
Using FastAPI Depends
-
{% if resources %}
Resources for this provider:
{% endif %}
+
- {% if settings.show_token and id_token_parsed %}
+ {% if show_token and id_token_parsed %}
diff --git a/src/oidc_test/templates/non_compliant_logout.html b/src/oidc_test/templates/non_compliant_logout.html
index 24a96ae..56758de 100644
--- a/src/oidc_test/templates/non_compliant_logout.html
+++ b/src/oidc_test/templates/non_compliant_logout.html
@@ -6,12 +6,12 @@
authorisation to log in again without asking for credentials.
- This is because {{ oidc_provider.name }} does not provide "end_session_endpoint" in its metadata
- (see: {{ oidc_provider._server_metadata_url }} ).
+ This is because {{ auth_provider.name }} does not provide "end_session_endpoint" in its metadata
+ (see: {{ auth_provider.authlib_client._server_metadata_url }} ).
You can just also go back to the application home page , but
- it recommended to go to the OIDC provider's site
+ it recommended to go to the OIDC provider's site
and log out explicitely from there.
{% endblock %}
From e56be3c378e10e4ac972a3901021226ed26c7c1f Mon Sep 17 00:00:00 2001
From: phil
Date: Mon, 10 Feb 2025 14:14:32 +0100
Subject: [PATCH 27/70] Continue refactor
---
.../{auth_provider.py => auth/provider.py} | 8 ++-----
.../{auth_utils.py => auth/utils.py} | 24 +++++++++----------
src/oidc_test/auth_providers.py | 5 ++++
src/oidc_test/database.py | 4 +++-
src/oidc_test/main.py | 12 ++++++----
src/oidc_test/models.py | 2 +-
src/oidc_test/resource_server.py | 8 ++++---
src/oidc_test/settings.py | 4 +---
src/oidc_test/static/styles.css | 3 +--
src/oidc_test/templates/home.html | 2 +-
10 files changed, 38 insertions(+), 34 deletions(-)
rename src/oidc_test/{auth_provider.py => auth/provider.py} (94%)
rename src/oidc_test/{auth_utils.py => auth/utils.py} (96%)
create mode 100644 src/oidc_test/auth_providers.py
diff --git a/src/oidc_test/auth_provider.py b/src/oidc_test/auth/provider.py
similarity index 94%
rename from src/oidc_test/auth_provider.py
rename to src/oidc_test/auth/provider.py
index e50241c..dab4764 100644
--- a/src/oidc_test/auth_provider.py
+++ b/src/oidc_test/auth/provider.py
@@ -2,14 +2,13 @@ from json import JSONDecodeError
from typing import Any
from jwt import decode
import logging
-from collections import OrderedDict
from pydantic import ConfigDict
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
from httpx import AsyncClient
-from .settings import AuthProviderSettings, settings
-from .models import User
+from ..settings import AuthProviderSettings, settings
+from ..models import User
logger = logging.getLogger("oidc-test")
@@ -90,6 +89,3 @@ class Provider(AuthProviderSettings):
def get_session_key(self, userinfo):
return userinfo[self.session_key]
-
-
-providers: OrderedDict[str, Provider] = OrderedDict()
diff --git a/src/oidc_test/auth_utils.py b/src/oidc_test/auth/utils.py
similarity index 96%
rename from src/oidc_test/auth_utils.py
rename to src/oidc_test/auth/utils.py
index 8cd5028..0623186 100644
--- a/src/oidc_test/auth_utils.py
+++ b/src/oidc_test/auth/utils.py
@@ -10,15 +10,15 @@ from jwt import ExpiredSignatureError, InvalidKeyError, DecodeError, PyJWTError
# from authlib.oauth1.auth import OAuthToken
from authlib.oauth2.auth import OAuth2Token
-from .models import User
-from .database import db, TokenNotInDb, UserNotInDB
-from .settings import settings
-from .auth_provider import providers, Provider
+from .provider import Provider
+
+from ..models import User
+from ..database import db, TokenNotInDb, UserNotInDB
+from ..settings import settings
+from ..auth_providers import providers
logger = logging.getLogger("oidc-test")
-oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
-
async def fetch_token(name, request):
assert name is not None
@@ -51,9 +51,6 @@ async def update_token(
# await item.save()
-authlib_oauth = OAuth(cache=None, fetch_token=fetch_token, update_token=update_token)
-
-
def init_providers():
"""Add oidc providers to authlib from the settings
and build the providers dict"""
@@ -86,7 +83,8 @@ def init_providers():
providers[provider.id] = provider
-init_providers()
+authlib_oauth = OAuth(cache=None, fetch_token=fetch_token, update_token=update_token)
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def get_auth_provider_client_or_none(request: Request) -> StarletteOAuth2App | None:
@@ -245,7 +243,7 @@ async def get_user_from_token(
except ExpiredSignatureError:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
- "Expired signature (refresh not implemented yet)",
+ "Expired signature (token refresh not implemented yet)",
)
except InvalidKeyError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid auth provider key")
@@ -263,12 +261,12 @@ async def get_user_from_token(
user.access_token = token
except UserNotInDB:
logger.info(
- f"User {user_id} not found in DB, creating it (real apps can behave differently"
+ f"User {user_id} not found in DB, creating it (real apps can behave differently)"
)
user = await db.add_user(
sub=payload["sub"],
user_info=payload,
- auth_provider=getattr(authlib_oauth, auth_provider_id),
+ auth_provider=providers[auth_provider_id],
access_token=token,
)
return user
diff --git a/src/oidc_test/auth_providers.py b/src/oidc_test/auth_providers.py
new file mode 100644
index 0000000..45f4de6
--- /dev/null
+++ b/src/oidc_test/auth_providers.py
@@ -0,0 +1,5 @@
+from collections import OrderedDict
+
+from .auth.provider import Provider
+
+providers: OrderedDict[str, Provider] = OrderedDict()
diff --git a/src/oidc_test/database.py b/src/oidc_test/database.py
index 659fd13..4704f9b 100644
--- a/src/oidc_test/database.py
+++ b/src/oidc_test/database.py
@@ -5,8 +5,10 @@ import logging
from authlib.oauth2.rfc6749 import OAuth2Token
from jwt import PyJWTError
+from .auth.provider import Provider
+
from .models import User, Role
-from .auth_provider import Provider, providers
+from .auth_providers import providers
logger = logging.getLogger("oidc-test")
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 4018997..f37339d 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -26,10 +26,8 @@ from authlib.oauth2.rfc6749 import OAuth2Token
# from fastapi.security import OpenIdConnect
# from pkce import generate_code_verifier, generate_pkce_pair
-from .settings import settings
-from .auth_provider import NoPublicKey, Provider, providers
-from .models import User
-from .auth_utils import (
+from .auth.provider import NoPublicKey, Provider
+from .auth.utils import (
get_auth_provider,
get_auth_provider_or_none,
get_current_user_or_none,
@@ -38,6 +36,11 @@ from .auth_utils import (
get_token,
update_token,
)
+
+from .auth.utils import init_providers
+from .settings import settings
+from .auth_providers import providers
+from .models import User
from .database import TokenNotInDb, db
from .resource_server import resource_server
@@ -49,6 +52,7 @@ templates = Jinja2Templates(Path(__file__).parent / "templates")
@asynccontextmanager
async def lifespan(app: FastAPI):
assert app is not None
+ init_providers()
for provider in list(providers.values()):
try:
await provider.get_info()
diff --git a/src/oidc_test/models.py b/src/oidc_test/models.py
index 7c5250b..7b6fd0e 100644
--- a/src/oidc_test/models.py
+++ b/src/oidc_test/models.py
@@ -56,7 +56,7 @@ class User(UserBase):
def decode_access_token(self, verify_signature: bool = True):
assert self.access_token is not None, "no access_token"
assert self.auth_provider_id is not None, "no auth_provider_id"
- from .auth_provider import providers
+ from .auth_providers import providers
return providers[self.auth_provider_id].decode(
self.access_token, verify_signature=verify_signature
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index f7f0433..15084bc 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -12,14 +12,16 @@ from fastapi.middleware.cors import CORSMiddleware
# from authlib.integrations.starlette_client.apps import StarletteOAuth2App
# from authlib.oauth2.rfc6749 import OAuth2Token
-from .models import User
-from .auth_utils import (
+from .auth.provider import Provider
+from .auth.utils import (
get_token_or_none,
get_user_from_token,
UserWithRole,
)
+
+from .auth_providers import providers
from .settings import settings
-from .auth_provider import providers, Provider
+from .models import User
logger = logging.getLogger("oidc-test")
diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py
index 86a2b6b..f3ac8f3 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -13,8 +13,6 @@ from pydantic_settings import (
)
from starlette.requests import Request
-from .models import User
-
class Resource(BaseModel):
"""A resource with an URL that can be accessed with an OAuth2 access token"""
@@ -57,7 +55,7 @@ class AuthProviderSettings(BaseModel):
def token_url(self) -> str:
return "auth/" + self.id
- def get_account_url(self, request: Request, user: User) -> str | None:
+ def get_account_url(self, request: Request, user: dict) -> str | None:
if self.account_url_template:
if not (self.url.endswith("/") or self.account_url_template.startswith("/")):
sep = "/"
diff --git a/src/oidc_test/static/styles.css b/src/oidc_test/static/styles.css
index 367ea99..e163a68 100644
--- a/src/oidc_test/static/styles.css
+++ b/src/oidc_test/static/styles.css
@@ -184,11 +184,10 @@ hr {
font-family: monospace;
}
-.resource {
+.resourceResult {
padding: 0.5em;
display: flex;
gap: 0.5em;
- flex-direction: column;
width: fit-content;
align-items: center;
margin: 5px auto;
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index da513c9..93d0bc6 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -51,7 +51,7 @@
{% endif %}
{% if auth_provider.account_url_template %}
Account management
From 64f6a90f22c82a6813cd7e68f2ab193fdbd1980b Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 11 Feb 2025 17:27:49 +0100
Subject: [PATCH 28/70] Add resource provided registry and plugin system
---
src/oidc_test/auth/provider.py | 4 +-
src/oidc_test/auth/utils.py | 11 +-
src/oidc_test/auth_providers.py | 2 +-
src/oidc_test/database.py | 8 +-
src/oidc_test/main.py | 22 +--
src/oidc_test/registry.py | 43 ++++++
src/oidc_test/resource_server.py | 229 +++++++++++++++++-------------
src/oidc_test/settings.py | 2 +-
src/oidc_test/static/utils.js | 9 +-
src/oidc_test/templates/home.html | 41 +++---
10 files changed, 229 insertions(+), 142 deletions(-)
create mode 100644 src/oidc_test/registry.py
diff --git a/src/oidc_test/auth/provider.py b/src/oidc_test/auth/provider.py
index dab4764..17dcaa0 100644
--- a/src/oidc_test/auth/provider.py
+++ b/src/oidc_test/auth/provider.py
@@ -7,8 +7,8 @@ from pydantic import ConfigDict
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
from httpx import AsyncClient
-from ..settings import AuthProviderSettings, settings
-from ..models import User
+from oidc_test.settings import AuthProviderSettings, settings
+from oidc_test.models import User
logger = logging.getLogger("oidc-test")
diff --git a/src/oidc_test/auth/utils.py b/src/oidc_test/auth/utils.py
index 0623186..9479c48 100644
--- a/src/oidc_test/auth/utils.py
+++ b/src/oidc_test/auth/utils.py
@@ -10,12 +10,11 @@ from jwt import ExpiredSignatureError, InvalidKeyError, DecodeError, PyJWTError
# from authlib.oauth1.auth import OAuthToken
from authlib.oauth2.auth import OAuth2Token
-from .provider import Provider
-
-from ..models import User
-from ..database import db, TokenNotInDb, UserNotInDB
-from ..settings import settings
-from ..auth_providers import providers
+from oidc_test.auth.provider import Provider
+from oidc_test.models import User
+from oidc_test.database import db, TokenNotInDb, UserNotInDB
+from oidc_test.settings import settings
+from oidc_test.auth_providers import providers
logger = logging.getLogger("oidc-test")
diff --git a/src/oidc_test/auth_providers.py b/src/oidc_test/auth_providers.py
index 45f4de6..1c33ae8 100644
--- a/src/oidc_test/auth_providers.py
+++ b/src/oidc_test/auth_providers.py
@@ -1,5 +1,5 @@
from collections import OrderedDict
-from .auth.provider import Provider
+from oidc_test.auth.provider import Provider
providers: OrderedDict[str, Provider] = OrderedDict()
diff --git a/src/oidc_test/database.py b/src/oidc_test/database.py
index 4704f9b..8d87a48 100644
--- a/src/oidc_test/database.py
+++ b/src/oidc_test/database.py
@@ -5,10 +5,10 @@ import logging
from authlib.oauth2.rfc6749 import OAuth2Token
from jwt import PyJWTError
-from .auth.provider import Provider
+from oidc_test.auth.provider import Provider
-from .models import User, Role
-from .auth_providers import providers
+from oidc_test.models import User, Role
+from oidc_test.auth_providers import providers
logger = logging.getLogger("oidc-test")
@@ -23,6 +23,7 @@ class TokenNotInDb(Exception):
class Database:
users: dict[str, User] = {}
+ # TODO: key of the token table should be provider: sid
tokens: dict[str, OAuth2Token] = {}
# Last sessions for the user (key: users's subject id (sub))
@@ -82,6 +83,7 @@ class Database:
provider: Provider,
sid: str | None,
) -> OAuth2Token:
+ # TODO: key of the token table should be provider: sid
assert isinstance(provider, Provider)
if sid is None:
raise TokenNotInDb
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index f37339d..28eab8a 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -26,8 +26,9 @@ from authlib.oauth2.rfc6749 import OAuth2Token
# from fastapi.security import OpenIdConnect
# from pkce import generate_code_verifier, generate_pkce_pair
-from .auth.provider import NoPublicKey, Provider
-from .auth.utils import (
+from oidc_test.registry import registry
+from oidc_test.auth.provider import NoPublicKey, Provider
+from oidc_test.auth.utils import (
get_auth_provider,
get_auth_provider_or_none,
get_current_user_or_none,
@@ -36,13 +37,12 @@ from .auth.utils import (
get_token,
update_token,
)
-
-from .auth.utils import init_providers
-from .settings import settings
-from .auth_providers import providers
-from .models import User
-from .database import TokenNotInDb, db
-from .resource_server import resource_server
+from oidc_test.auth.utils import init_providers
+from oidc_test.settings import settings
+from oidc_test.auth_providers import providers
+from oidc_test.models import User
+from oidc_test.database import TokenNotInDb, db
+from oidc_test.resource_server import resource_server
logger = logging.getLogger("oidc-test")
@@ -53,6 +53,7 @@ templates = Jinja2Templates(Path(__file__).parent / "templates")
async def lifespan(app: FastAPI):
assert app is not None
init_providers()
+ registry.make_registry()
for provider in list(providers.values()):
try:
await provider.get_info()
@@ -104,6 +105,7 @@ async def home(
context["resources"] = None
else:
context["access_token"] = token["access_token"]
+ # XXX: resources defined externally? I am confused...
context["resources"] = provider.resources
try:
access_token_parsed = provider.decode(token["access_token"], verify_signature=False)
@@ -113,9 +115,9 @@ async def home(
context["access_token_scope"] = access_token_parsed["scope"]
except KeyError:
context["access_token_scope"] = None
- # context["id_token_parsed"] = pretty_details(user, now)
context["id_token_parsed"] = provider.decode(token["id_token"], verify_signature=False)
context["access_token_parsed"] = access_token_parsed
+ context["resource_providers"] = registry.resource_providers
try:
context["refresh_token_parsed"] = provider.decode(
token["refresh_token"], verify_signature=False
diff --git a/src/oidc_test/registry.py b/src/oidc_test/registry.py
new file mode 100644
index 0000000..6db0a47
--- /dev/null
+++ b/src/oidc_test/registry.py
@@ -0,0 +1,43 @@
+from importlib.metadata import entry_points
+import logging
+from typing import Any
+
+from pydantic import BaseModel
+
+from oidc_test.models import User
+
+logger = logging.getLogger("registry")
+
+
+class ProcessResult(BaseModel):
+ result: dict[str, Any] = {}
+
+
+class ProcessError(Exception):
+ pass
+
+
+class ResourceProvider:
+ name: str
+ scope_required: str | None = None
+ default_resource_id: str | None = None
+
+ def __init__(self, name: str):
+ self.name = name
+
+ async def process(self, user: User, resource_id: str | None = None) -> ProcessResult:
+ logger.warning(f"{self.name} should define a process method")
+ return ProcessResult()
+
+
+class ResourceRegistry:
+ resource_providers: dict[str, ResourceProvider] = {}
+
+ def make_registry(self):
+ for ep in entry_points().select(group="oidc_test.resource_provider"):
+ ResourceProviderClass = ep.load()
+ if issubclass(ResourceProviderClass, ResourceProvider):
+ self.resource_providers[ep.name] = ResourceProviderClass(ep.name)
+
+
+registry = ResourceRegistry()
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index 15084bc..3b89240 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -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:
diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py
index f3ac8f3..2acbc3f 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -17,7 +17,7 @@ from starlette.requests import Request
class Resource(BaseModel):
"""A resource with an URL that can be accessed with an OAuth2 access token"""
- id: str
+ resource_name: str
name: str
url: str
diff --git a/src/oidc_test/static/utils.js b/src/oidc_test/static/utils.js
index 6c9fae4..978b61c 100644
--- a/src/oidc_test/static/utils.js
+++ b/src/oidc_test/static/utils.js
@@ -1,6 +1,8 @@
async function checkHref(elem, token, authProvider) {
const msg = document.getElementById("msg")
- const url = `resource/${elem.getAttribute("resource-id")}`
+ const resourceName = elem.getAttribute("resource-name")
+ const resourceId = elem.getAttribute("resource-id")
+ const url = resourceId ? `resource/${resourceName}/${resourceId}` : `resource/${resourceName}`
const resp = await fetch(url, {
method: "GET",
headers: new Headers({
@@ -28,11 +30,12 @@ function checkPerms(className, token, authProvider) {
)
}
-async function get_resource(id, token, authProvider) {
+async function get_resource(resource_name, token, authProvider, resource_id) {
//if (!keycloak.keycloak) { return }
const msg = document.getElementById("msg")
const resourceElem = document.getElementById('resource')
- const resp = await fetch("resource/" + id, {
+ const url = resource_id ? `resource/${resource_name}/${resource_id}` : `resource/${resource_name}`
+ const resp = await fetch(url, {
method: "GET",
headers: new Headers({
"Content-type": "application/json",
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index 93d0bc6..790da81 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -62,33 +62,42 @@
{% endif %}
-
- Resources validated by scope:
-
-
- Time
- BS
-
Resources validated by role:
- Public
- Auth protected content
- Auth + foorole protected content
- Auth + foorole or barrole protected content
- Auth + barrole protected content
- Auth + foorole and barrole protected content
- Using FastAPI Depends
-
+ Public
+ Auth protected content
+ Auth + foorole protected content
+ Auth + foorole or barrole protected content
+ Auth + barrole protected content
+ Auth + foorole and barrole protected content
+ Using FastAPI Depends
+
+
+ {% if resource_providers %}
+
+ Resource providers (validated by scope):
+
+
+ {% for name, resource_provider in resource_providers.items() %}
+ {% if resource_provider.default_resource_id %}
+ {{ name }}
+ {% else %}
+ {{ name }}
+ {% endif %}
{% endfor %}
{% endif %}
From 0464047f8a6d17590f06fc7c52c4b1d4e007b2ce Mon Sep 17 00:00:00 2001
From: phil
Date: Wed, 12 Feb 2025 03:21:06 +0100
Subject: [PATCH 29/70] Container: add demo plugin
---
Containerfile | 3 +++
src/oidc_test/templates/home.html | 2 +-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/Containerfile b/Containerfile
index aef57f8..75fef2b 100644
--- a/Containerfile
+++ b/Containerfile
@@ -9,6 +9,9 @@ WORKDIR /app
RUN uv pip install --system .
+# Add demo plugin
+RUN PIP_EXTRA_INDEX_URL=https://pypi.org/simple/ uv pip install --system --index-url https://code.philo.ydns.eu/api/packages/philorg/pypi/simple/ oidc-fastapi-test-petition
+
# Possible to run with:
#CMD ["oidc-test", "--port", "80"]
#CMD ["fastapi", "run", "src/oidc_test/main.py", "--port", "8873", "--root-path", "/oidc-test"]
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index 790da81..23ba7ff 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -56,7 +56,7 @@
Account management
{% endif %}
- Refresh
+ Refresh access token
Logout
{% endif %}
From 381ce1ebc175d899cca49de14b8b7b6a6b263866 Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 13 Feb 2025 12:23:18 +0100
Subject: [PATCH 30/70] Use pydantic on ResourceServer
---
src/oidc_test/main.py | 3 +--
src/oidc_test/registry.py | 10 +++++-----
src/oidc_test/resource_server.py | 13 ++++++++++---
3 files changed, 16 insertions(+), 10 deletions(-)
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 28eab8a..3858a08 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -58,7 +58,7 @@ async def lifespan(app: FastAPI):
try:
await provider.get_info()
except NoPublicKey:
- logger.warn(f"Disable {provider.id}: public key not found")
+ logger.warning(f"Disable {provider.id}: public key not found")
del providers[provider.id]
yield
@@ -300,7 +300,6 @@ async def refresh(
await update_token(provider.id, new_token)
return RedirectResponse(url=request.url_for("home"))
-
# Snippet for running standalone
# Mostly useful for the --version option,
# as running with uvicorn is easy and provides better flexibility, eg.
diff --git a/src/oidc_test/registry.py b/src/oidc_test/registry.py
index 6db0a47..a184ec0 100644
--- a/src/oidc_test/registry.py
+++ b/src/oidc_test/registry.py
@@ -17,20 +17,20 @@ class ProcessError(Exception):
pass
-class ResourceProvider:
- name: str
+class ResourceProvider(BaseModel):
scope_required: str | None = None
default_resource_id: str | None = None
def __init__(self, name: str):
- self.name = name
+ super().__init__()
+ self.__name__ = name
async def process(self, user: User, resource_id: str | None = None) -> ProcessResult:
- logger.warning(f"{self.name} should define a process method")
+ logger.warning(f"{self.__name__} should define a process method")
return ProcessResult()
-class ResourceRegistry:
+class ResourceRegistry(BaseModel):
resource_providers: dict[str, ResourceProvider] = {}
def make_registry(self):
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index 3b89240..1af0f6b 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-from typing import Annotated
+from typing import Annotated, Any
import logging
from authlib.oauth2.auth import OAuth2Token
@@ -21,7 +20,7 @@ from oidc_test.auth.utils import (
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
+from oidc_test.registry import ProcessError, ProcessResult, ResourceProvider, registry
logger = logging.getLogger("oidc-test")
@@ -48,6 +47,14 @@ resource_server.add_middleware(
# Routes for RBAC based tests
+@resource_server.get("/")
+async def resources() -> dict[str, dict[str, Any]]:
+ return {
+ "internal": {},
+ "plugins": registry.resource_providers
+ }
+
+
@resource_server.get("/public")
async def public() -> dict:
return {"msg": "Not protected"}
From 9d3146dc1c895d7a4eebace39af6222ebbbea091 Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 13 Feb 2025 18:15:26 +0100
Subject: [PATCH 31/70] Add role protection to resource servers, remove
hardcoded resources
---
src/oidc_test/auth/utils.py | 51 ++++++++-----
src/oidc_test/main.py | 3 +-
src/oidc_test/registry.py | 7 +-
src/oidc_test/resource_server.py | 118 +++++++++++++++++-------------
src/oidc_test/settings.py | 1 +
src/oidc_test/static/styles.css | 14 +++-
src/oidc_test/templates/home.html | 43 +++--------
7 files changed, 127 insertions(+), 110 deletions(-)
diff --git a/src/oidc_test/auth/utils.py b/src/oidc_test/auth/utils.py
index 9479c48..acd68b5 100644
--- a/src/oidc_test/auth/utils.py
+++ b/src/oidc_test/auth/utils.py
@@ -8,7 +8,7 @@ from authlib.integrations.starlette_client import OAuth, OAuthError, StarletteOA
from jwt import ExpiredSignatureError, InvalidKeyError, DecodeError, PyJWTError
# from authlib.oauth1.auth import OAuthToken
-from authlib.oauth2.auth import OAuth2Token
+from authlib.oauth2.rfc6749 import OAuth2Token
from oidc_test.auth.provider import Provider
from oidc_test.models import User
@@ -60,25 +60,28 @@ def init_providers():
sub="", auth_provider_id=provider_settings.id
)
provider = Provider(**provider_settings_dict)
- authlib_oauth.register(
- name=provider.id,
- server_metadata_url=provider.openid_configuration,
- client_kwargs={
- "scope": " ".join(
- ["openid", "email", "offline_access", "profile"]
- + provider.resource_provider_scopes
- ),
- },
- client_id=provider.client_id,
- client_secret=provider.client_secret,
- api_base_url=provider.url,
- # For PKCE (not implemented yet):
- # code_challenge_method="S256",
- fetch_token=fetch_token,
- update_token=update_token,
- # client_id="some-client-id", # if enabled, authlib will also check that the access token belongs to this client id (audience)
- )
- provider.authlib_client = getattr(authlib_oauth, provider.id)
+ if provider.disabled:
+ logger.info(f"{provider_settings.name} is disabled, skipping")
+ else:
+ authlib_oauth.register(
+ name=provider.id,
+ server_metadata_url=provider.openid_configuration,
+ client_kwargs={
+ "scope": " ".join(
+ ["openid", "email", "offline_access", "profile"]
+ + provider.resource_provider_scopes
+ ),
+ },
+ client_id=provider.client_id,
+ client_secret=provider.client_secret,
+ api_base_url=provider.url,
+ # For PKCE (not implemented yet):
+ # code_challenge_method="S256",
+ fetch_token=fetch_token,
+ update_token=update_token,
+ # client_id="some-client-id", # if enabled, authlib will also check that the access token belongs to this client id (audience)
+ )
+ provider.authlib_client = getattr(authlib_oauth, provider.id)
providers[provider.id] = provider
@@ -270,6 +273,14 @@ async def get_user_from_token(
)
return user
+async def get_user_from_token_or_none(
+ token: Annotated[str, Depends(oauth2_scheme)],
+ request: Request,
+) -> User | None:
+ try:
+ return await get_user_from_token(token, request)
+ except HTTPException:
+ return None
class UserWithRole:
roles: set[str]
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 3858a08..9e8b135 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -55,6 +55,8 @@ async def lifespan(app: FastAPI):
init_providers()
registry.make_registry()
for provider in list(providers.values()):
+ if provider.disabled:
+ continue
try:
await provider.get_info()
except NoPublicKey:
@@ -106,7 +108,6 @@ async def home(
else:
context["access_token"] = token["access_token"]
# XXX: resources defined externally? I am confused...
- context["resources"] = provider.resources
try:
access_token_parsed = provider.decode(token["access_token"], verify_signature=False)
except PyJWTError as err:
diff --git a/src/oidc_test/registry.py b/src/oidc_test/registry.py
index a184ec0..e9c9809 100644
--- a/src/oidc_test/registry.py
+++ b/src/oidc_test/registry.py
@@ -18,15 +18,18 @@ class ProcessError(Exception):
class ResourceProvider(BaseModel):
+ name: str
scope_required: str | None = None
+ role_required: str | None = None
+ is_public: bool = False
default_resource_id: str | None = None
def __init__(self, name: str):
super().__init__()
- self.__name__ = name
+ self.__id__ = name
async def process(self, user: User, resource_id: str | None = None) -> ProcessResult:
- logger.warning(f"{self.__name__} should define a process method")
+ logger.warning(f"{self.__id__} should define a process method")
return ProcessResult()
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index 1af0f6b..03d109e 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -1,7 +1,7 @@
from typing import Annotated, Any
import logging
-from authlib.oauth2.auth import OAuth2Token
+from authlib.oauth2.rfc6749 import OAuth2Token
from httpx import AsyncClient
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from fastapi import FastAPI, HTTPException, Depends, Request, status
@@ -16,6 +16,7 @@ from oidc_test.auth.utils import (
get_token_or_none,
get_user_from_token,
UserWithRole,
+ get_user_from_token_or_none,
)
from oidc_test.auth_providers import providers
from oidc_test.settings import settings
@@ -55,54 +56,12 @@ async def resources() -> dict[str, dict[str, Any]]:
}
-@resource_server.get("/public")
-async def public() -> dict:
- return {"msg": "Not protected"}
-
-
-@resource_server.get("/protected")
-async def get_protected(user: Annotated[User, Depends(get_user_from_token)]):
- assert user is not None # Just to keep QA checks happy
- 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"))],
-):
- 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"))],
-):
- 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"))],
-):
- assert user is not None # Just to keep QA checks happy
- 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"]))],
-):
- assert user is not None # Just to keep QA checks happy
- 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)],
+ user: Annotated[User, Depends(get_user_from_token_or_none)],
token: Annotated[OAuth2Token | None, Depends(get_token_or_none)],
request: Request,
resource_id: str | None = None,
@@ -111,19 +70,29 @@ async def get_resource(
provider = providers[user.auth_provider_id]
# Third party resource (provided through the auth provider)
# The token is just passed on
+ breakpoint()
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,
+ token=token,
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
- ):
+ reasons: dict[str, str] = {}
+ if not resource_provider.is_public:
+ if resource_provider.scope_required is not None and not user.has_scope(
+ resource_provider.scope_required
+ ):
+ reasons["scope"] = f"No scope {resource_provider.scope_required} in the access token " \
+ "but it is required for accessing this resource"
+ if resource_provider.role_required is not None \
+ and resource_provider.role_required not in user.roles_as_set:
+ reasons["role"] = f"You don't have the role {resource_provider.role_required} " \
+ "but it is required for accessing this resource"
+ if len(reasons) == 0:
try:
return await resource_provider.process(user=user, resource_id=resource_id)
except ProcessError as err:
@@ -132,9 +101,7 @@ async def get_resource(
)
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",
+ status.HTTP_401_UNAUTHORIZED, ", ".join(reasons.values())
)
else:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Unknown resource {resource_name}")
@@ -142,8 +109,13 @@ async def get_resource(
async def get_auth_provider_resource(
- provider: Provider, resource_name: str, access_token: str | None, user: User
+ provider: Provider, resource_name: str, token: OAuth2Token | None, user: User
) -> ProcessResult:
+ if token is None:
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED, f"No auth token"
+ )
+ access_token = token["access_token"]
resource = [r for r in provider.resources if r.resource_name == resource_name][0]
async with AsyncClient() as client:
resp = await client.get(
@@ -165,6 +137,48 @@ async def get_auth_provider_resource(
return ProcessResult(result=resp.json())
+#@resource_server.get("/public")
+#async def public() -> dict:
+# return {"msg": "Not protected"}
+#
+#
+#@resource_server.get("/protected")
+#async def get_protected(user: Annotated[User, Depends(get_user_from_token)]):
+# assert user is not None # Just to keep QA checks happy
+# 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"))],
+#):
+# 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"))],
+#):
+# 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"))],
+#):
+# assert user is not None # Just to keep QA checks happy
+# 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"]))],
+#):
+# assert user is not None # Just to keep QA checks happy
+# return {"msg": "Only users with foorole or barrole can see this"}
+
# async def get_resource_(resource_id: str, user: User, **kwargs) -> dict:
# """
# Resource processing: build an informative rely as a simple showcase
diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py
index 2acbc3f..3e7001c 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -44,6 +44,7 @@ class AuthProviderSettings(BaseModel):
resource_provider_scopes: list[str] = []
session_key: str = "sid"
skip_verify_signature: bool = True
+ disabled: bool = False
@computed_field
@property
diff --git a/src/oidc_test/static/styles.css b/src/oidc_test/static/styles.css
index e163a68..2baa748 100644
--- a/src/oidc_test/static/styles.css
+++ b/src/oidc_test/static/styles.css
@@ -142,19 +142,27 @@ hr {
.providers .provider {
min-height: 2em;
}
-.providers .provider a.link {
+.providers .provider .link {
text-decoration: none;
max-height: 2em;
}
-.providers .provider .link div {
+.providers .provider .link {
background-color: #f7c7867d;
border-radius: 8px;
padding: 6px;
text-align: center;
color: black;
- font-weight: bold;
+ font-weight: 400;
cursor: pointer;
+ border: 0;
+ width: 100%;
}
+
+.providers .provider .link.disabled {
+ color: gray;
+ cursor: not-allowed;
+}
+
.providers .provider .hint {
font-size: 80%;
max-width: 13em;
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index 23ba7ff..ecefb0f 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -11,7 +11,11 @@
{% for provider in providers.values() %}
- {{ provider.name }}
+
+ {{ provider.name }}
+
{{ provider.hint }}
@@ -62,42 +66,17 @@
{% endif %}
-
- Resources validated by role:
-
-
- Public
- Auth protected content
- Auth + foorole protected content
- Auth + foorole or barrole protected content
- Auth + barrole protected content
- Auth + foorole and barrole protected content
- Using FastAPI Depends
-
-
-
{% if resource_providers %}
- Resource providers (validated by scope):
+ Resource providers:
{% for name, resource_provider in resource_providers.items() %}
- {% if resource_provider.default_resource_id %}
- {{ name }}
- {% else %}
- {{ name }}
- {% endif %}
+ {% if resource_provider.default_resource_id %}
+ {{ name }}
+ {% else %}
+ {{ name }}
+ {% endif %}
{% endfor %}
{% endif %}
From 5bd4b8280427d4a070a724cee141ee52ed3664a4 Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 13 Feb 2025 18:26:23 +0100
Subject: [PATCH 32/70] Update demo resource provider package name
---
Containerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Containerfile b/Containerfile
index 75fef2b..2e3fd28 100644
--- a/Containerfile
+++ b/Containerfile
@@ -10,7 +10,7 @@ WORKDIR /app
RUN uv pip install --system .
# Add demo plugin
-RUN PIP_EXTRA_INDEX_URL=https://pypi.org/simple/ uv pip install --system --index-url https://code.philo.ydns.eu/api/packages/philorg/pypi/simple/ oidc-fastapi-test-petition
+RUN PIP_EXTRA_INDEX_URL=https://pypi.org/simple/ uv pip install --system --index-url https://code.philo.ydns.eu/api/packages/philorg/pypi/simple/ oidc-fastapi-test-resource-provider-demo
# Possible to run with:
#CMD ["oidc-test", "--port", "80"]
From 40ddb616363e303968f1b9d5e7291f0868ac7656 Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 13 Feb 2025 18:26:48 +0100
Subject: [PATCH 33/70] Cleanup
---
src/oidc_test/resource_server.py | 1 -
src/oidc_test/templates/home.html | 15 ++++++++++++---
2 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index 03d109e..1877875 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -70,7 +70,6 @@ async def get_resource(
provider = providers[user.auth_provider_id]
# Third party resource (provided through the auth provider)
# The token is just passed on
- breakpoint()
if resource_name in [r.resource_name for r in provider.resources]:
return await get_auth_provider_resource(
provider=provider,
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index ecefb0f..6c4e6a6 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -68,14 +68,23 @@
{% if resource_providers %}
- Resource providers:
+ {{ auth_provider.name }} provides these resources:
{% for name, resource_provider in resource_providers.items() %}
{% if resource_provider.default_resource_id %}
- {{ name }}
+
+ {{ resource_provider.name }}
+
{% else %}
- {{ name }}
+
+ {{ resource_provider.name }}
+
{% endif %}
{% endfor %}
From c89ca4098b2165014890af89caebde7310b88db0 Mon Sep 17 00:00:00 2001
From: phil
Date: Fri, 14 Feb 2025 13:21:55 +0100
Subject: [PATCH 34/70] Fix public resource access; free resource response
validation; formatting
---
src/oidc_test/auth/utils.py | 15 ++--
src/oidc_test/main.py | 10 +--
src/oidc_test/registry.py | 9 ++-
src/oidc_test/resource_server.py | 127 ++++++++++++++++---------------
4 files changed, 84 insertions(+), 77 deletions(-)
diff --git a/src/oidc_test/auth/utils.py b/src/oidc_test/auth/utils.py
index acd68b5..7dd0e3d 100644
--- a/src/oidc_test/auth/utils.py
+++ b/src/oidc_test/auth/utils.py
@@ -87,6 +87,7 @@ def init_providers():
authlib_oauth = OAuth(cache=None, fetch_token=fetch_token, update_token=update_token)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
+oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
def get_auth_provider_client_or_none(request: Request) -> StarletteOAuth2App | None:
@@ -125,7 +126,7 @@ async def get_current_user(request: Request) -> User:
"""
if (user_sub := request.session.get("user_sub")) is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
- token = await get_token(request)
+ token = await get_token_from_session(request)
user = await db.get_user(user_sub)
## Check if the token is expired
if token.is_expired():
@@ -146,16 +147,16 @@ async def get_current_user(request: Request) -> User:
return user
-async def get_token_or_none(request: Request) -> OAuth2Token | None:
+async def get_token_from_session_or_none(request: Request) -> OAuth2Token | None:
"""Return the auth token from the session or None.
Can be used in Depends()"""
try:
- return await get_token(request)
+ return await get_token_from_session(request)
except HTTPException:
return None
-async def get_token(request: Request) -> OAuth2Token:
+async def get_token_from_session(request: Request) -> OAuth2Token:
"""Return the token from the session.
Can be used in Depends()"""
try:
@@ -273,15 +274,19 @@ async def get_user_from_token(
)
return user
+
async def get_user_from_token_or_none(
- token: Annotated[str, Depends(oauth2_scheme)],
+ token: Annotated[str | None, Depends(oauth2_scheme_optional)],
request: Request,
) -> User | None:
+ if token is None:
+ return None
try:
return await get_user_from_token(token, request)
except HTTPException:
return None
+
class UserWithRole:
roles: set[str]
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 9e8b135..9f5e746 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -33,8 +33,8 @@ from oidc_test.auth.utils import (
get_auth_provider_or_none,
get_current_user_or_none,
authlib_oauth,
- get_token_or_none,
- get_token,
+ get_token_from_session_or_none,
+ get_token_from_session,
update_token,
)
from oidc_test.auth.utils import init_providers
@@ -88,9 +88,9 @@ app.mount("/resource", resource_server, name="resource_server")
@app.get("/")
async def home(
request: Request,
- user: Annotated[User, Depends(get_current_user_or_none)],
+ user: Annotated[User | None, Depends(get_current_user_or_none)],
provider: Annotated[Provider | None, Depends(get_auth_provider_or_none)],
- token: Annotated[OAuth2Token | None, Depends(get_token_or_none)],
+ token: Annotated[OAuth2Token | None, Depends(get_token_from_session_or_none)],
) -> HTMLResponse:
context = {
"show_token": settings.show_token,
@@ -291,7 +291,7 @@ async def non_compliant_logout(
async def refresh(
request: Request,
provider: Annotated[Provider, Depends(get_auth_provider)],
- token: Annotated[OAuth2Token, Depends(get_token)],
+ token: Annotated[OAuth2Token, Depends(get_token_from_session)],
) -> RedirectResponse:
"""Manually refresh token"""
new_token = await provider.authlib_client.fetch_access_token(
diff --git a/src/oidc_test/registry.py b/src/oidc_test/registry.py
index e9c9809..794a843 100644
--- a/src/oidc_test/registry.py
+++ b/src/oidc_test/registry.py
@@ -1,8 +1,7 @@
from importlib.metadata import entry_points
import logging
-from typing import Any
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict
from oidc_test.models import User
@@ -10,7 +9,9 @@ logger = logging.getLogger("registry")
class ProcessResult(BaseModel):
- result: dict[str, Any] = {}
+ model_config = ConfigDict(
+ extra="allow",
+ )
class ProcessError(Exception):
@@ -28,7 +29,7 @@ class ResourceProvider(BaseModel):
super().__init__()
self.__id__ = name
- async def process(self, user: User, resource_id: str | None = None) -> ProcessResult:
+ async def process(self, user: User | None, resource_id: str | None = None) -> ProcessResult:
logger.warning(f"{self.__id__} should define a process method")
return ProcessResult()
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index 1877875..ee4ff10 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -13,15 +13,13 @@ from fastapi.middleware.cors import CORSMiddleware
from oidc_test.auth.provider import Provider
from oidc_test.auth.utils import (
- get_token_or_none,
- get_user_from_token,
- UserWithRole,
get_user_from_token_or_none,
+ oauth2_scheme_optional,
)
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, ResourceProvider, registry
+from oidc_test.registry import ProcessError, ProcessResult, registry
logger = logging.getLogger("oidc-test")
@@ -50,60 +48,67 @@ resource_server.add_middleware(
@resource_server.get("/")
async def resources() -> dict[str, dict[str, Any]]:
- return {
- "internal": {},
- "plugins": registry.resource_providers
- }
-
+ return {"internal": {}, "plugins": registry.resource_providers}
@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_or_none)],
- token: Annotated[OAuth2Token | None, Depends(get_token_or_none)],
- request: Request,
+ user: Annotated[User | None, Depends(get_user_from_token_or_none)],
+ token: Annotated[OAuth2Token | None, Depends(oauth2_scheme_optional)],
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,
- token=token,
- user=user,
- )
+):
+ """Generic path for testing a resource provided by a provider.
+ There's no field validation (response type of ProcessResult) on purpose,
+ leaving the responsibility of the response validation to resource providers"""
+ # Get the resource if it's defined in user auth provider's resources (external)
+ if user is not None:
+ 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,
+ token=token,
+ user=user,
+ )
# Internal resource (provided here)
if resource_name in registry.resource_providers:
resource_provider = registry.resource_providers[resource_name]
- reasons: dict[str, str] = {}
+ reason: dict[str, str] = {}
if not resource_provider.is_public:
- if resource_provider.scope_required is not None and not user.has_scope(
- resource_provider.scope_required
- ):
- reasons["scope"] = f"No scope {resource_provider.scope_required} in the access token " \
- "but it is required for accessing this resource"
- if resource_provider.role_required is not None \
- and resource_provider.role_required not in user.roles_as_set:
- reasons["role"] = f"You don't have the role {resource_provider.role_required} " \
- "but it is required for accessing this resource"
- if len(reasons) == 0:
+ if user is None:
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Resource is not public")
+ else:
+ if resource_provider.scope_required is not None and not user.has_scope(
+ resource_provider.scope_required
+ ):
+ reason["scope"] = (
+ f"No scope {resource_provider.scope_required} in the access token "
+ "but it is required for accessing this resource"
+ )
+ if (
+ resource_provider.role_required is not None
+ and resource_provider.role_required not in user.roles_as_set
+ ):
+ reason["role"] = (
+ f"You don't have the role {resource_provider.role_required} "
+ "but it is required for accessing this resource"
+ )
+ if len(reason) == 0:
try:
- return await resource_provider.process(user=user, resource_id=resource_id)
+ resp = await resource_provider.process(user=user, resource_id=resource_id)
+ return resp
except ProcessError as err:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, f"Cannot process resource: {err}"
)
else:
- raise HTTPException(
- status.HTTP_401_UNAUTHORIZED, ", ".join(reasons.values())
- )
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, ", ".join(reason.values()))
else:
- raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Unknown resource {resource_name}")
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Unknown resource")
# return await get_resource_(resource_name, user, **request.query_params)
@@ -111,9 +116,7 @@ async def get_auth_provider_resource(
provider: Provider, resource_name: str, token: OAuth2Token | None, user: User
) -> ProcessResult:
if token is None:
- raise HTTPException(
- status.HTTP_401_UNAUTHORIZED, f"No auth token"
- )
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"No auth token")
access_token = token["access_token"]
resource = [r for r in provider.resources if r.resource_name == resource_name][0]
async with AsyncClient() as client:
@@ -129,52 +132,50 @@ async def get_auth_provider_resource(
# 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"}
- )
+ return ProcessResult(msg=f"The resource is too long ({resp_length} bytes) to show here")
else:
- return ProcessResult(result=resp.json())
+ return ProcessResult(**resp.json())
-#@resource_server.get("/public")
-#async def public() -> dict:
+# @resource_server.get("/public")
+# async def public() -> dict:
# return {"msg": "Not protected"}
#
#
-#@resource_server.get("/protected")
-#async def get_protected(user: Annotated[User, Depends(get_user_from_token)]):
+# @resource_server.get("/protected")
+# async def get_protected(user: Annotated[User, Depends(get_user_from_token)]):
# assert user is not None # Just to keep QA checks happy
# return {"msg": "Only authenticated users can see this"}
#
#
-#@resource_server.get("/protected-by-foorole")
-#async def get_protected_by_foorole(
+# @resource_server.get("/protected-by-foorole")
+# async def get_protected_by_foorole(
# 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(
+# @resource_server.get("/protected-by-barrole")
+# async def get_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(
+# @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"))],
-#):
+# ):
# assert user is not None # Just to keep QA checks happy
# 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(
+# @resource_server.get("/protected-by-foorole-or-barrole")
+# async def get_protected_by_foorole_or_barrole(
# user: Annotated[User, Depends(UserWithRole(["foorole", "barrole"]))],
-#):
+# ):
# assert user is not None # Just to keep QA checks happy
# return {"msg": "Only users with foorole or barrole can see this"}
From 4008036bca36ae933b14fba5ed239ddd0e727d57 Mon Sep 17 00:00:00 2001
From: phil
Date: Fri, 14 Feb 2025 13:36:22 +0100
Subject: [PATCH 35/70] CI: don't fail because of publish step (already exists)
---
.forgejo/workflows/build.yaml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml
index e02bf47..352a0a9 100644
--- a/.forgejo/workflows/build.yaml
+++ b/.forgejo/workflows/build.yaml
@@ -90,3 +90,4 @@ jobs:
env:
LOCAL_PYPI_TOKEN: ${{ secrets.LOCAL_PYPI_TOKEN }}
run: uv publish --publish-url https://code.philo.ydns.eu/api/packages/philorg/pypi --token $LOCAL_PYPI_TOKEN
+ continue-on-error: true
From 1c57944a902aa25bf7bd4527b4d83bfb6d1b81e2 Mon Sep 17 00:00:00 2001
From: phil
Date: Mon, 17 Feb 2025 17:26:30 +0100
Subject: [PATCH 36/70] Fix typo
---
src/oidc_test/main.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 9f5e746..79293a3 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -264,7 +264,7 @@ async def logout(
{
"post_logout_redirect_uri": post_logout_uri,
"id_token_hint": token["id_token"],
- "cliend_id": "oidc_local_test",
+ "client_id": "oidc_local_test",
}
)
)
@@ -301,6 +301,7 @@ async def refresh(
await update_token(provider.id, new_token)
return RedirectResponse(url=request.url_for("home"))
+
# Snippet for running standalone
# Mostly useful for the --version option,
# as running with uvicorn is easy and provides better flexibility, eg.
From 435c11b6caf7ca59529a62eec664a2bbcf2d554a Mon Sep 17 00:00:00 2001
From: phil
Date: Wed, 19 Feb 2025 04:07:57 +0100
Subject: [PATCH 37/70] Working use as third party resource provider
---
src/oidc_test/auth/provider.py | 18 ++++++++-
src/oidc_test/main.py | 26 ++++++-------
src/oidc_test/registry.py | 10 ++---
src/oidc_test/resource_server.py | 62 +++++++++++++++++++++++--------
src/oidc_test/settings.py | 28 +++++++++-----
src/oidc_test/static/utils.js | 10 +++--
src/oidc_test/templates/home.html | 43 +++++++++++++++------
7 files changed, 138 insertions(+), 59 deletions(-)
diff --git a/src/oidc_test/auth/provider.py b/src/oidc_test/auth/provider.py
index 17dcaa0..c614805 100644
--- a/src/oidc_test/auth/provider.py
+++ b/src/oidc_test/auth/provider.py
@@ -7,7 +7,7 @@ from pydantic import ConfigDict
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
from httpx import AsyncClient
-from oidc_test.settings import AuthProviderSettings, settings
+from oidc_test.settings import AuthProviderSettings, ResourceProvider, Resource, settings
from oidc_test.models import User
logger = logging.getLogger("oidc-test")
@@ -24,6 +24,7 @@ class Provider(AuthProviderSettings):
authlib_client: StarletteOAuth2App = StarletteOAuth2App(None)
info: dict[str, Any] = {}
unknown_auth_user: User
+ logout_with_id_token_hint: bool = True
def decode(self, token: str, verify_signature: bool | None = None) -> dict[str, Any]:
"""Decode the token with signature check"""
@@ -89,3 +90,18 @@ class Provider(AuthProviderSettings):
def get_session_key(self, userinfo):
return userinfo[self.session_key]
+
+ def get_resource(self, resource_name: str) -> Resource:
+ return [
+ resource for resource in self.resources if resource.resource_name == resource_name
+ ][0]
+
+ def get_resource_url(self, resource_name: str) -> str:
+ return self.url + self.get_resource(resource_name).url
+
+ def get_resource_provider(self, resource_provider_id: str) -> ResourceProvider:
+ return [
+ provider
+ for provider in self.resource_providers
+ if provider.id == resource_provider_id
+ ][0]
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 79293a3..f930e48 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -107,7 +107,6 @@ async def home(
context["resources"] = None
else:
context["access_token"] = token["access_token"]
- # XXX: resources defined externally? I am confused...
try:
access_token_parsed = provider.decode(token["access_token"], verify_signature=False)
except PyJWTError as err:
@@ -118,7 +117,8 @@ async def home(
context["access_token_scope"] = None
context["id_token_parsed"] = provider.decode(token["id_token"], verify_signature=False)
context["access_token_parsed"] = access_token_parsed
- context["resource_providers"] = registry.resource_providers
+ context["resources"] = registry.resources
+ context["resource_providers"] = provider.resource_providers
try:
context["refresh_token_parsed"] = provider.decode(
token["refresh_token"], verify_signature=False
@@ -246,7 +246,7 @@ async def logout(
if (
provider_logout_uri := provider.authlib_client.server_metadata.get("end_session_endpoint")
) is None:
- logger.warn(f"Cannot find end_session_endpoint for provider {provider.id}")
+ logger.warning(f"Cannot find end_session_endpoint for provider {provider.id}")
return RedirectResponse(request.url_for("non_compliant_logout"))
post_logout_uri = request.url_for("home")
# Clear session
@@ -255,19 +255,15 @@ async def logout(
try:
token = await db.get_token(provider, request.session.pop("sid", None))
except TokenNotInDb:
- logger.warn("No session in db for the token or no token")
+ logger.warning("No session in db for the token or no token")
return RedirectResponse(request.url_for("home"))
- logout_url = (
- provider_logout_uri
- + "?"
- + urlencode(
- {
- "post_logout_redirect_uri": post_logout_uri,
- "id_token_hint": token["id_token"],
- "client_id": "oidc_local_test",
- }
- )
- )
+ url_query = {
+ "post_logout_redirect_uri": post_logout_uri,
+ "client_id": provider.client_id,
+ }
+ if provider.logout_with_id_token_hint:
+ url_query["id_token_hint"] = token["id_token"]
+ logout_url = f"{provider_logout_uri}?{urlencode(url_query)}"
return RedirectResponse(logout_url)
diff --git a/src/oidc_test/registry.py b/src/oidc_test/registry.py
index 794a843..3b91ad4 100644
--- a/src/oidc_test/registry.py
+++ b/src/oidc_test/registry.py
@@ -18,7 +18,7 @@ class ProcessError(Exception):
pass
-class ResourceProvider(BaseModel):
+class Resource(BaseModel):
name: str
scope_required: str | None = None
role_required: str | None = None
@@ -35,13 +35,13 @@ class ResourceProvider(BaseModel):
class ResourceRegistry(BaseModel):
- resource_providers: dict[str, ResourceProvider] = {}
+ resources: dict[str, Resource] = {}
def make_registry(self):
for ep in entry_points().select(group="oidc_test.resource_provider"):
- ResourceProviderClass = ep.load()
- if issubclass(ResourceProviderClass, ResourceProvider):
- self.resource_providers[ep.name] = ResourceProviderClass(ep.name)
+ ResourceClass = ep.load()
+ if issubclass(ResourceClass, Resource):
+ self.resources[ep.name] = ResourceClass(ep.name)
registry = ResourceRegistry()
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index ee4ff10..a4d5368 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -17,7 +17,7 @@ from oidc_test.auth.utils import (
oauth2_scheme_optional,
)
from oidc_test.auth_providers import providers
-from oidc_test.settings import settings
+from oidc_test.settings import ResourceProvider, settings
from oidc_test.models import User
from oidc_test.registry import ProcessError, ProcessResult, registry
@@ -48,7 +48,7 @@ resource_server.add_middleware(
@resource_server.get("/")
async def resources() -> dict[str, dict[str, Any]]:
- return {"internal": {}, "plugins": registry.resource_providers}
+ return {"internal": {}, "plugins": registry.resources}
@resource_server.get("/{resource_name}")
@@ -65,8 +65,41 @@ async def get_resource(
# Get the resource if it's defined in user auth provider's resources (external)
if user is not None:
provider = providers[user.auth_provider_id]
+ if ":" in resource_name:
+ # Third-party resource provider: send the request with the request token
+ resource_provider_id, resource_name = resource_name.split(":", 1)
+ provider = providers[user.auth_provider_id]
+ resource_provider: ResourceProvider = provider.get_resource_provider(
+ resource_provider_id
+ )
+ resource_url = resource_provider.get_resource_url(resource_name)
+ async with AsyncClient(verify=resource_provider.verify_ssl) as client:
+ try:
+ resp = await client.get(
+ resource_url,
+ headers={
+ "Content-type": "application/json",
+ "Authorization": f"Bearer {token}",
+ "auth_provider": user.auth_provider_id,
+ },
+ )
+ except Exception as err:
+ raise HTTPException(
+ status.HTTP_500_INTERNAL_SERVER_ERROR, err.__class__.__name__
+ )
+ else:
+ if resp.is_success:
+ return resp.json()
+ else:
+ reason_str: str
+ try:
+ reason_str = resp.json().get("detail", str(resp))
+ except Exception:
+ reason_str = str(resp.text)
+ raise HTTPException(resp.status_code, reason_str)
# Third party resource (provided through the auth provider)
# The token is just passed on
+ # XXX: is this branch valid anymore?
if resource_name in [r.resource_name for r in provider.resources]:
return await get_auth_provider_resource(
provider=provider,
@@ -75,31 +108,31 @@ async def get_resource(
user=user,
)
# Internal resource (provided here)
- if resource_name in registry.resource_providers:
- resource_provider = registry.resource_providers[resource_name]
+ if resource_name in registry.resources:
+ resource = registry.resources[resource_name]
reason: dict[str, str] = {}
- if not resource_provider.is_public:
+ if not resource.is_public:
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Resource is not public")
else:
- if resource_provider.scope_required is not None and not user.has_scope(
- resource_provider.scope_required
+ if resource.scope_required is not None and not user.has_scope(
+ resource.scope_required
):
reason["scope"] = (
- f"No scope {resource_provider.scope_required} in the access token "
+ f"No scope {resource.scope_required} in the access token "
"but it is required for accessing this resource"
)
if (
- resource_provider.role_required is not None
- and resource_provider.role_required not in user.roles_as_set
+ resource.role_required is not None
+ and resource.role_required not in user.roles_as_set
):
reason["role"] = (
- f"You don't have the role {resource_provider.role_required} "
+ f"You don't have the role {resource.role_required} "
"but it is required for accessing this resource"
)
if len(reason) == 0:
try:
- resp = await resource_provider.process(user=user, resource_id=resource_id)
+ resp = await resource.process(user=user, resource_id=resource_id)
return resp
except ProcessError as err:
raise HTTPException(
@@ -116,12 +149,11 @@ async def get_auth_provider_resource(
provider: Provider, resource_name: str, token: OAuth2Token | None, user: User
) -> ProcessResult:
if token is None:
- raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"No auth token")
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No auth token")
access_token = token["access_token"]
- 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,
+ url=provider.get_resource_url(resource_name),
headers={
"Content-type": "application/json",
"Authorization": f"Bearer {access_token}",
diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py
index 3e7001c..e549fd4 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -4,7 +4,7 @@ import random
from typing import Type, Tuple
from pathlib import Path
-from pydantic import BaseModel, computed_field, AnyUrl
+from pydantic import AnyHttpUrl, BaseModel, computed_field, AnyUrl
from pydantic_settings import (
BaseSettings,
SettingsConfigDict,
@@ -22,6 +22,22 @@ class Resource(BaseModel):
url: str
+class ResourceProvider(BaseModel):
+ id: str
+ name: str
+ base_url: AnyUrl
+ resources: list[Resource] = []
+ verify_ssl: bool = True
+
+ def get_resource(self, resource_name: str) -> Resource:
+ return [
+ resource for resource in self.resources if resource.resource_name == resource_name
+ ][0]
+
+ def get_resource_url(self, resource_name: str) -> str:
+ return f"{self.base_url}{self.get_resource(resource_name).url}"
+
+
class AuthProviderSettings(BaseModel):
"""Auth provider, can also be a resource server"""
@@ -45,6 +61,7 @@ class AuthProviderSettings(BaseModel):
session_key: str = "sid"
skip_verify_signature: bool = True
disabled: bool = False
+ resource_providers: list[ResourceProvider] = []
@computed_field
@property
@@ -67,13 +84,6 @@ class AuthProviderSettings(BaseModel):
return None
-class ResourceProvider(BaseModel):
- id: str
- name: str
- base_url: AnyUrl
- resources: list[Resource] = []
-
-
class AuthSettings(BaseModel):
show_session_details: bool = False
providers: list[AuthProviderSettings] = []
@@ -92,13 +102,13 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_nested_delimiter="__")
auth: AuthSettings = AuthSettings()
- resource_providers: list[ResourceProvider] = []
secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16))
log: bool = False
insecure: Insecure = Insecure()
cors_origins: list[str] = []
debug_token: bool = False
show_token: bool = False
+ show_external_resource_providers_links: bool = False
@classmethod
def settings_customise_sources(
diff --git a/src/oidc_test/static/utils.js b/src/oidc_test/static/utils.js
index 978b61c..e988dfe 100644
--- a/src/oidc_test/static/utils.js
+++ b/src/oidc_test/static/utils.js
@@ -2,7 +2,9 @@ async function checkHref(elem, token, authProvider) {
const msg = document.getElementById("msg")
const resourceName = elem.getAttribute("resource-name")
const resourceId = elem.getAttribute("resource-id")
- const url = resourceId ? `resource/${resourceName}/${resourceId}` : `resource/${resourceName}`
+ const resourceProviderId = elem.getAttribute("resource-provider-id") ? elem.getAttribute("resource-provider-id") : ""
+ const fqResourceName = resourceProviderId ? `${resourceProviderId}:${resourceName}` : resourceName
+ const url = resourceId ? `resource/${fqResourceName}/${resourceId}` : `resource/${fqResourceName}`
const resp = await fetch(url, {
method: "GET",
headers: new Headers({
@@ -30,11 +32,13 @@ function checkPerms(className, token, authProvider) {
)
}
-async function get_resource(resource_name, token, authProvider, resource_id) {
+async function get_resource(resourceName, token, authProvider, resourceId, resourceProviderId) {
+ // BaseUrl for an external resource provider
//if (!keycloak.keycloak) { return }
const msg = document.getElementById("msg")
const resourceElem = document.getElementById('resource')
- const url = resource_id ? `resource/${resource_name}/${resource_id}` : `resource/${resource_name}`
+ const fqResourceName = resourceProviderId ? `${resourceProviderId}:${resourceName}` : resourceName
+ const url = resourceId ? `resource/${fqResourceName}/${resourceId}` : `resource/${fqResourceName}`
const resp = await fetch(url, {
method: "GET",
headers: new Headers({
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index 6c4e6a6..5bccaee 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -66,29 +66,50 @@
{% endif %}
- {% if resource_providers %}
+
+ {% if resources %}
{{ auth_provider.name }} provides these resources:
- {% for name, resource_provider in resource_providers.items() %}
- {% if resource_provider.default_resource_id %}
-
- {{ resource_provider.name }}
-
+ {% for name, resource in resources.items() %}
+ {% if resource.default_resource_id %}
+
+ {{ resource.name }}
+
{% else %}
- {{ resource_provider.name }}
-
+ {{ resource.name }}
+
{% endif %}
{% endfor %}
{% endif %}
+ {% if resource_providers %}
+
{{ auth_provider.name }} can request resources from third party resource providers:
+ {% for resource_provider in resource_providers %}
+
+ {{ resource_provider.name }}
+ {% for resource in resource_provider.resources %}
+
+ {{ resource.name }}
+
+ {% endfor %}
+
+ {% endfor %}
+ {% endif %}
From e925f2176258d4f73b5b7e565123652da02d6b12 Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 20 Feb 2025 02:01:18 +0100
Subject: [PATCH 38/70] Add configurable logging from settings
---
log_conf.yaml => src/oidc_test/log_conf.yaml | 0
src/oidc_test/main.py | 13 +++++++++++++
src/oidc_test/settings.py | 1 +
3 files changed, 14 insertions(+)
rename log_conf.yaml => src/oidc_test/log_conf.yaml (100%)
diff --git a/log_conf.yaml b/src/oidc_test/log_conf.yaml
similarity index 100%
rename from log_conf.yaml
rename to src/oidc_test/log_conf.yaml
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index f930e48..cf28f27 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -6,6 +6,9 @@ from typing import Annotated
from pathlib import Path
from datetime import datetime
import logging
+import logging.config
+import importlib.resources
+from yaml import safe_load
from urllib.parse import urlencode
from contextlib import asynccontextmanager
@@ -46,6 +49,16 @@ from oidc_test.resource_server import resource_server
logger = logging.getLogger("oidc-test")
+if settings.log:
+ assert __package__ is not None
+ with (
+ importlib.resources.path(__package__) as package_path,
+ open(package_path / settings.log_config_file) as f,
+ ):
+ logging_config = safe_load(f)
+ logging.config.dictConfig(logging_config)
+
+breakpoint()
templates = Jinja2Templates(Path(__file__).parent / "templates")
diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py
index e549fd4..ad80c06 100644
--- a/src/oidc_test/settings.py
+++ b/src/oidc_test/settings.py
@@ -104,6 +104,7 @@ class Settings(BaseSettings):
auth: AuthSettings = AuthSettings()
secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16))
log: bool = False
+ log_config_file: str = "log_conf.yaml"
insecure: Insecure = Insecure()
cors_origins: list[str] = []
debug_token: bool = False
From 703985f31102705769e725b25d54dc8414bb0610 Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 20 Feb 2025 02:01:33 +0100
Subject: [PATCH 39/70] Add configurable logging from settings
---
src/oidc_test/main.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index cf28f27..8fe32d8 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -58,7 +58,6 @@ if settings.log:
logging_config = safe_load(f)
logging.config.dictConfig(logging_config)
-breakpoint()
templates = Jinja2Templates(Path(__file__).parent / "templates")
From 0764b1c003151ddbf7afe6d6d17590aca0913de2 Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 20 Feb 2025 02:05:15 +0100
Subject: [PATCH 40/70] Log request to resource server
---
src/oidc_test/resource_server.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index a4d5368..604052c 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -75,6 +75,7 @@ async def get_resource(
resource_url = resource_provider.get_resource_url(resource_name)
async with AsyncClient(verify=resource_provider.verify_ssl) as client:
try:
+ logger.debug(f"GET request to {resource_url}")
resp = await client.get(
resource_url,
headers={
From d924c56b1710082690df1e6fd8a146e8f672cf6d Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 20 Feb 2025 02:56:28 +0100
Subject: [PATCH 41/70] Cosmetic
---
src/oidc_test/templates/home.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index 5bccaee..7d2b1db 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -70,7 +70,7 @@
-->
{% if resources %}
- {{ auth_provider.name }} provides these resources:
+ This application provides all these resources, eventually protected with roles:
{% for name, resource in resources.items() %}
@@ -92,7 +92,7 @@
{% endif %}
{% if resource_providers %}
- {{ auth_provider.name }} can request resources from third party resource providers:
+ {{ auth_provider.name }} allows this applicaiton to request resources from third party resource providers:
{% for resource_provider in resource_providers %}
{{ resource_provider.name }}
From ecdd3702f85c04e62741fa15910e25f425d5c20d Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 20 Feb 2025 03:13:41 +0100
Subject: [PATCH 42/70] Hanle token refresh error
---
src/oidc_test/main.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 8fe32d8..8808562 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -306,7 +306,13 @@ async def refresh(
refresh_token=token["refresh_token"],
grant_type="refresh_token",
)
- await update_token(provider.id, new_token)
+ try:
+ await update_token(provider.id, new_token)
+ except PyJWTError as err:
+ logger.info(f"Cannot refresh token: {err.__class__.__name__}")
+ raise HTTPException(
+ status.HTTP_510_NOT_EXTENDED, f"Token refresh error: {err.__class__.__name__}"
+ )
return RedirectResponse(url=request.url_for("home"))
From 3f945310a4aba2b898c7647728b9840dda9796c1 Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 20 Feb 2025 03:20:09 +0100
Subject: [PATCH 43/70] Cosmetic
---
src/oidc_test/templates/home.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index 7d2b1db..3c1ff3c 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -70,7 +70,7 @@
-->
{% if resources %}
- This application provides all these resources, eventually protected with roles:
+ This application provides all these resources, eventually protected with scope or roles:
{% for name, resource in resources.items() %}
From 347c3953943a1e0b7d84d5555727fae2f1d104be Mon Sep 17 00:00:00 2001
From: phil
Date: Thu, 20 Feb 2025 21:16:43 +0100
Subject: [PATCH 44/70] Fix auth provider resources
---
src/oidc_test/main.py | 25 +++++++++++++------------
src/oidc_test/resource_server.py | 25 ++++++++++++++++++++-----
src/oidc_test/templates/home.html | 29 +++++++++++++++++++++++------
3 files changed, 56 insertions(+), 23 deletions(-)
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 8808562..54d69c5 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -108,7 +108,6 @@ async def home(
"show_token": settings.show_token,
"user": user,
"now": datetime.now(),
- "auth_provider": provider,
}
if provider is None or token is None:
context["providers"] = providers
@@ -117,26 +116,28 @@ async def home(
context["access_token_parsed"] = None
context["refresh_token_parsed"] = None
context["resources"] = None
+ context["auth_provider"] = None
else:
+ context["auth_provider"] = provider
context["access_token"] = token["access_token"]
try:
access_token_parsed = provider.decode(token["access_token"], verify_signature=False)
+ context["access_token_parsed"] = access_token_parsed
except PyJWTError as err:
access_token_parsed = {"Cannot parse": err.__class__.__name__}
try:
- context["access_token_scope"] = access_token_parsed["scope"]
- except KeyError:
- context["access_token_scope"] = None
- context["id_token_parsed"] = provider.decode(token["id_token"], verify_signature=False)
- context["access_token_parsed"] = access_token_parsed
+ id_token_parsed = provider.decode(token["id_token"], verify_signature=False)
+ context["id_token_parsed"] = id_token_parsed
+ except PyJWTError as err:
+ id_token_parsed = {"Cannot parse": err.__class__.__name__}
+ try:
+ refresh_token_parsed = provider.decode(token["refresh_token"], verify_signature=False)
+ context["refresh_token_parsed"] = refresh_token_parsed
+ except PyJWTError as err:
+ refresh_token_parsed = {"Cannot parse": err.__class__.__name__}
+ context["access_token_scope"] = access_token_parsed.get("scope")
context["resources"] = registry.resources
context["resource_providers"] = provider.resource_providers
- try:
- context["refresh_token_parsed"] = provider.decode(
- token["refresh_token"], verify_signature=False
- )
- except PyJWTError as err:
- context["refresh_token_parsed"] = {"Cannot parse": err.__class__.__name__}
return templates.TemplateResponse(name="home.html", request=request, context=context)
diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py
index 604052c..ddc5762 100644
--- a/src/oidc_test/resource_server.py
+++ b/src/oidc_test/resource_server.py
@@ -1,9 +1,10 @@
from typing import Annotated, Any
import logging
+from json import JSONDecodeError
from authlib.oauth2.rfc6749 import OAuth2Token
-from httpx import AsyncClient
-from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
+from httpx import AsyncClient, HTTPError
+from jwt.exceptions import DecodeError, ExpiredSignatureError, InvalidTokenError
from fastapi import FastAPI, HTTPException, Depends, Request, status
from fastapi.middleware.cors import CORSMiddleware
@@ -84,6 +85,10 @@ async def get_resource(
"auth_provider": user.auth_provider_id,
},
)
+ except HTTPError as err:
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE, err.__class__.__name__
+ )
except Exception as err:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, err.__class__.__name__
@@ -151,7 +156,7 @@ async def get_auth_provider_resource(
) -> ProcessResult:
if token is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No auth token")
- access_token = token["access_token"]
+ access_token = token
async with AsyncClient() as client:
resp = await client.get(
url=provider.get_resource_url(resource_name),
@@ -165,9 +170,19 @@ async def get_auth_provider_resource(
# Only a demo, real application would really process the response
resp_length = len(resp.text)
if resp_length > 1024:
- return ProcessResult(msg=f"The resource is too long ({resp_length} bytes) to show here")
+ return ProcessResult(
+ msg=f"The resource is too long ({resp_length} bytes) to show in this demo, here is just the begining in raw format",
+ start=resp.text[:100] + "...",
+ )
else:
- return ProcessResult(**resp.json())
+ try:
+ resp_json = resp.json()
+ except JSONDecodeError:
+ return ProcessResult(msg="The resource is not formatted in JSON", text=resp.text)
+ if isinstance(resp_json, dict):
+ return ProcessResult(**resp.json())
+ elif isinstance(resp_json, list):
+ return ProcessResult(**{str(i): line for i, line in enumerate(resp_json)})
# @resource_server.get("/public")
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index 3c1ff3c..b4460ee 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -66,12 +66,8 @@
{% endif %}
-
{% if resources %}
-
- This application provides all these resources, eventually protected with scope or roles:
-
+
This application provides all these resources, eventually protected with scope or roles:
{% for name, resource in resources.items() %}
{% if resource.default_resource_id %}
@@ -91,8 +87,29 @@
{% endfor %}
{% endif %}
+ {% if auth_provider.resources %}
+
{{ auth_provider.name }} is also defined as a provider for these resources:
+
+ {% for resource in auth_provider.resources %}
+ {% if resource.default_resource_id %}
+
+ {{ resource.name }}
+
+ {% else %}
+
+ {{ resource.name }}
+
+ {% endif %}
+ {% endfor %}
+
+ {% endif %}
{% if resource_providers %}
-
{{ auth_provider.name }} allows this applicaiton to request resources from third party resource providers:
+
{{ auth_provider.name }} allows this application to request resources from third party resource providers:
{% for resource_provider in resource_providers %}
{{ resource_provider.name }}
From 4c2b197850a1e4e16f7b4d690eeb0ecd123422c5 Mon Sep 17 00:00:00 2001
From: phil
Date: Sat, 22 Feb 2025 14:02:05 +0100
Subject: [PATCH 45/70] Cosmetic
---
src/oidc_test/auth/utils.py | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/src/oidc_test/auth/utils.py b/src/oidc_test/auth/utils.py
index 7dd0e3d..134131e 100644
--- a/src/oidc_test/auth/utils.py
+++ b/src/oidc_test/auth/utils.py
@@ -5,10 +5,8 @@ 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, DecodeError, PyJWTError
-
-# from authlib.oauth1.auth import OAuthToken
from authlib.oauth2.rfc6749 import OAuth2Token
+from jwt import ExpiredSignatureError, InvalidKeyError, DecodeError, PyJWTError
from oidc_test.auth.provider import Provider
from oidc_test.models import User
From f6a84fd3aaef82460c3a701ad4dc451aeb7ac73e Mon Sep 17 00:00:00 2001
From: phil
Date: Sat, 22 Feb 2025 18:57:25 +0100
Subject: [PATCH 46/70] Cosmetic
---
src/oidc_test/auth/utils.py | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/oidc_test/auth/utils.py b/src/oidc_test/auth/utils.py
index 134131e..c51b039 100644
--- a/src/oidc_test/auth/utils.py
+++ b/src/oidc_test/auth/utils.py
@@ -20,7 +20,7 @@ logger = logging.getLogger("oidc-test")
async def fetch_token(name, request):
assert name is not None
assert request is not None
- logger.warn("TODO: fetch_token")
+ logger.warning("TODO: fetch_token")
...
# if name in oidc_providers:
# model = OAuth2Token
@@ -32,7 +32,10 @@ async def fetch_token(name, request):
async def update_token(
- provider_id, token, refresh_token: str | None = None, access_token: str | None = None
+ provider_id,
+ token,
+ refresh_token: str | None = None,
+ access_token: str | None = None,
):
"""Update the token in the database"""
provider = providers[provider_id]
From 850db9f59035645cb8530625388ae42ec69103c9 Mon Sep 17 00:00:00 2001
From: phil
Date: Sun, 23 Feb 2025 16:37:47 +0100
Subject: [PATCH 47/70] Fix scope cannot be determined when the access token
cannot be decoded
---
src/oidc_test/main.py | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index 54d69c5..e5238c8 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -123,19 +123,20 @@ async def home(
try:
access_token_parsed = provider.decode(token["access_token"], verify_signature=False)
context["access_token_parsed"] = access_token_parsed
+ context["access_token_scope"] = access_token_parsed.get("scope")
except PyJWTError as err:
- access_token_parsed = {"Cannot parse": err.__class__.__name__}
+ context["access_token_parsed"] = {"Cannot parse": err.__class__.__name__}
+ context["access_token_scope"] = None
try:
id_token_parsed = provider.decode(token["id_token"], verify_signature=False)
context["id_token_parsed"] = id_token_parsed
except PyJWTError as err:
- id_token_parsed = {"Cannot parse": err.__class__.__name__}
+ context["id_token_parsed"] = {"Cannot parse": err.__class__.__name__}
try:
refresh_token_parsed = provider.decode(token["refresh_token"], verify_signature=False)
context["refresh_token_parsed"] = refresh_token_parsed
except PyJWTError as err:
- refresh_token_parsed = {"Cannot parse": err.__class__.__name__}
- context["access_token_scope"] = access_token_parsed.get("scope")
+ context["refresh_token_parsed"] = {"Cannot parse": err.__class__.__name__}
context["resources"] = registry.resources
context["resource_providers"] = provider.resource_providers
return templates.TemplateResponse(name="home.html", request=request, context=context)
From 5f429797ff7a8656dee9dfc9cbc156dfe27f9c8f Mon Sep 17 00:00:00 2001
From: phil
Date: Sun, 23 Feb 2025 17:14:04 +0100
Subject: [PATCH 48/70] Fix auto check of auth provider resource (resource_name
in template)
---
src/oidc_test/templates/home.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/oidc_test/templates/home.html b/src/oidc_test/templates/home.html
index b4460ee..167616f 100644
--- a/src/oidc_test/templates/home.html
+++ b/src/oidc_test/templates/home.html
@@ -93,13 +93,13 @@
{% for resource in auth_provider.resources %}
{% if resource.default_resource_id %}
{{ resource.name }}
{% else %}
-
{{ resource.name }}
From 9249885c8080a9afaca61d22022cd9be2e6bd8bd Mon Sep 17 00:00:00 2001
From: phil
Date: Mon, 24 Feb 2025 03:29:23 +0100
Subject: [PATCH 49/70] Update README (config example)
---
README.md | 60 ++++++++++++++++++++++++++++++++++++++++---------------
1 file changed, 44 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index 9e00474..68f335d 100644
--- a/README.md
+++ b/README.md
@@ -52,31 +52,59 @@ given by the OIDC providers.
For example:
```yaml
-oidc:
- secret_key: "ASecretNoOneKnows"
- show_session_details: yes
+secret_key: AVeryWellKeptSecret
+debug_token: no
+show_token: yes
+log: yes
+
+auth:
providers:
- id: auth0
name: Okta / Auth0
- url: "https://"
- client_id: ""
- client_secret: "client_secret_generated_by_auth0"
- hint: "A hint for test credentials"
+ url: https://
+ public_key_url: https:///pem
+ client_id:
+ client_secret: client_secret_generated_by_auth0
+ hint: A hint for test credentials
- id: keycloak
name: Keycloak at somewhere
- url: "https://"
- account_url_template: "/account"
- client_id: ""
- client_secret: "client_secret_generated_by_keycloak"
- hint: "User: foo, password: foofoo"
+ url: https://
+ info_url: https://philo.ydns.eu/auth/realms/test
+ account_url_template: /account
+ client_id:
+ client_secret:
+ hint: A hint for test credentials
+ code_challenge_method: S256
+ resource_provider_scopes:
+ - get:time
+ - get:bs
+ resource_providers:
+ - id:
+ name: A third party resource provider
+ base_url: https://some.example.com/
+ verify_ssl: yes
+ resources:
+ - name: Public RS2
+ resource_name: public
+ url: resource/public
+ - name: BS RS2
+ resource_name: bs
+ url: resource/bs
+ - name: Time RS2
+ resource_name: time
+ url: resource/time
- id: codeberg
+ disabled: no
name: Codeberg
- url: "https://codeberg.org"
- account_url_template: "/user/settings"
- client_id: ""
- client_secret: "client_secret_generated_by_codeberg"
+ url: https://codeberg.org
+ account_url_template: /user/settings
+ client_id:
+ client_secret: client_secret_generated_by_codeberg
+ info_url: https://codeberg.org/login/oauth/keys
+ session_key: sub
+ skip_verify_signature: no
resources:
- name: List of repos
id: repos
From 395ec1c7f757ba919224c781fb76f53a0204ea34 Mon Sep 17 00:00:00 2001
From: phil
Date: Mon, 24 Feb 2025 19:56:00 +0100
Subject: [PATCH 50/70] Dynamic versioning
---
pyproject.toml | 15 +++++++++++----
uv.lock | 16 +++++++++++++++-
2 files changed, 26 insertions(+), 5 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index b1e6504..9c205e7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "oidc-fastapi-test"
-version = "0.0.0"
-# dynamic = ["version"]
+#version = "0.0.0"
+dynamic = ["version"]
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
@@ -24,12 +24,19 @@ dependencies = [
oidc-test = "oidc_test.main:main"
[dependency-groups]
-dev = ["ipdb>=0.13.13", "pytest>=8.3.4"]
+dev = [
+ "dunamai>=1.23.0",
+ "ipdb>=0.13.13",
+ "pytest>=8.3.4",
+]
[build-system]
-requires = ["hatchling"]
+requires = ["hatchling", "uv-dynamic-versioning"]
build-backend = "hatchling.build"
+[tool.hatch.version]
+source = "uv-dynamic-versioning"
+
[tool.hatch.build.targets.wheel]
packages = ["src/oidc_test"]
diff --git a/uv.lock b/uv.lock
index 01b64de..0566bb5 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,4 +1,5 @@
version = 1
+revision = 1
requires-python = ">=3.13"
[[package]]
@@ -206,6 +207,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 },
]
+[[package]]
+name = "dunamai"
+version = "1.23.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/4e/a5c8c337a1d9ac0384298ade02d322741fb5998041a5ea74d1cd2a4a1d47/dunamai-1.23.0.tar.gz", hash = "sha256:a163746de7ea5acb6dacdab3a6ad621ebc612ed1e528aaa8beedb8887fccd2c4", size = 44681 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/21/4c/963169386309fec4f96fd61210ac0a0666887d0fb0a50205395674d20b71/dunamai-1.23.0-py3-none-any.whl", hash = "sha256:a0906d876e92441793c6a423e16a4802752e723e9c9a5aabdc5535df02dbe041", size = 26342 },
+]
+
[[package]]
name = "ecdsa"
version = "0.19.0"
@@ -482,7 +495,6 @@ wheels = [
[[package]]
name = "oidc-fastapi-test"
-version = "0.0.0"
source = { editable = "." }
dependencies = [
{ name = "authlib" },
@@ -501,6 +513,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
+ { name = "dunamai" },
{ name = "ipdb" },
{ name = "pytest" },
]
@@ -523,6 +536,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
+ { name = "dunamai", specifier = ">=1.23.0" },
{ name = "ipdb", specifier = ">=0.13.13" },
{ name = "pytest", specifier = ">=8.3.4" },
]
From ef7c265d8e4b70d283d9d7d161165003b62ae7fa Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 00:38:43 +0100
Subject: [PATCH 51/70] Cleanup pyproject
---
pyproject.toml | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 9c205e7..f634fbe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,11 +24,7 @@ dependencies = [
oidc-test = "oidc_test.main:main"
[dependency-groups]
-dev = [
- "dunamai>=1.23.0",
- "ipdb>=0.13.13",
- "pytest>=8.3.4",
-]
+dev = ["dunamai>=1.23.0", "ipdb>=0.13.13", "pytest>=8.3.4"]
[build-system]
requires = ["hatchling", "uv-dynamic-versioning"]
@@ -39,6 +35,12 @@ source = "uv-dynamic-versioning"
[tool.hatch.build.targets.wheel]
packages = ["src/oidc_test"]
+package = true
+
+[tool.uv-dynamic-versioning]
+metadata = true
+dirty = true
+format = semver
[tool.uv]
package = true
From 9c1f84328399104e8759a5d8c2f12d4a7f4113b7 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 00:40:33 +0100
Subject: [PATCH 52/70] Cleanup pyproject
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index f634fbe..1e23a8d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,7 +40,7 @@ package = true
[tool.uv-dynamic-versioning]
metadata = true
dirty = true
-format = semver
+style = "semver"
[tool.uv]
package = true
From 3da485c945e221c73721f71540e0c57c12f7c22b Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 00:41:36 +0100
Subject: [PATCH 53/70] Cleanup pyproject
---
pyproject.toml | 1 -
1 file changed, 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 1e23a8d..8770c8d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -39,7 +39,6 @@ package = true
[tool.uv-dynamic-versioning]
metadata = true
-dirty = true
style = "semver"
[tool.uv]
From 9c462379051155cdf834dfd64309cc97da348f4c Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 01:37:17 +0100
Subject: [PATCH 54/70] Semver versioning, show version on web page
---
pyproject.toml | 1 -
src/oidc_test/__init__.py | 11 +++++++++++
src/oidc_test/main.py | 2 ++
src/oidc_test/static/styles.css | 6 ++++++
src/oidc_test/templates/base.html | 1 +
5 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 8770c8d..c44e9f3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,7 +38,6 @@ packages = ["src/oidc_test"]
package = true
[tool.uv-dynamic-versioning]
-metadata = true
style = "semver"
[tool.uv]
diff --git a/src/oidc_test/__init__.py b/src/oidc_test/__init__.py
index e69de29..f449e2b 100644
--- a/src/oidc_test/__init__.py
+++ b/src/oidc_test/__init__.py
@@ -0,0 +1,11 @@
+import importlib.metadata
+
+try:
+ from dunamai import Version, Style
+
+ __version__ = Version.from_git().serialize(style=Style.SemVer, dirty=True)
+except ImportError:
+ # __name__ could be used if the package name is the same
+ # as the directory - not the case here
+ # __version__ = importlib.metadata.version(__name__)
+ __version__ = importlib.metadata.version("oidc-fastapi-test")
diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py
index e5238c8..e882cda 100644
--- a/src/oidc_test/main.py
+++ b/src/oidc_test/main.py
@@ -29,6 +29,7 @@ from authlib.oauth2.rfc6749 import OAuth2Token
# from fastapi.security import OpenIdConnect
# from pkce import generate_code_verifier, generate_pkce_pair
+from oidc_test import __version__
from oidc_test.registry import registry
from oidc_test.auth.provider import NoPublicKey, Provider
from oidc_test.auth.utils import (
@@ -108,6 +109,7 @@ async def home(
"show_token": settings.show_token,
"user": user,
"now": datetime.now(),
+ "__version__": __version__,
}
if provider is None or token is None:
context["providers"] = providers
diff --git a/src/oidc_test/static/styles.css b/src/oidc_test/static/styles.css
index 2baa748..1e8dc03 100644
--- a/src/oidc_test/static/styles.css
+++ b/src/oidc_test/static/styles.css
@@ -21,6 +21,12 @@ hr {
.hidden {
display: none;
}
+.version {
+ position: absolute;
+ font-size: 75%;
+ top: 0.3em;
+ right: 0.3em;
+}
.center {
text-align: center;
}
diff --git a/src/oidc_test/templates/base.html b/src/oidc_test/templates/base.html
index 4cb56f5..157e26f 100644
--- a/src/oidc_test/templates/base.html
+++ b/src/oidc_test/templates/base.html
@@ -5,6 +5,7 @@
+ v. {{ __version__}}
OIDC-test - FastAPI client
{% block content %}
{% endblock %}
From b4653947660be16fbfee161fc4c52e2f4747f0f9 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 01:42:49 +0100
Subject: [PATCH 55/70] CI: WIP
---
.forgejo/workflows/build.yaml | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml
index 352a0a9..c58230e 100644
--- a/.forgejo/workflows/build.yaml
+++ b/.forgejo/workflows/build.yaml
@@ -27,6 +27,15 @@ jobs:
- name: Run tests (API call)
run: .venv/bin/pytest -s tests/basic.py
+ - name: Get version
+ uses: mtkennerly/dunamai-action@v1
+ with:
+ env-var: VERSION
+ args: --style semver
+
+ - name: Version
+ run: echo $VERSION
+
- name: Get version with git describe
id: version
run: |
From f4b38e1c69d3a1f288ddba1b62ae84ae4184d362 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 02:20:35 +0100
Subject: [PATCH 56/70] CI: use dunamai for version
---
.forgejo/workflows/build.yaml | 25 +++++++++++--------------
1 file changed, 11 insertions(+), 14 deletions(-)
diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml
index c58230e..9f21750 100644
--- a/.forgejo/workflows/build.yaml
+++ b/.forgejo/workflows/build.yaml
@@ -30,32 +30,29 @@ jobs:
- name: Get version
uses: mtkennerly/dunamai-action@v1
with:
- env-var: VERSION
args: --style semver
+ env-var: VERSION
- name: Version
run: echo $VERSION
- - name: Get version with git describe
- id: version
- run: |
- echo "version=$(git describe)" >> $GITHUB_OUTPUT
- echo "$VERSION"
+ - name: Get distance from tag
+ uses: mtkennerly/dunamai-action@v1
+ with:
+ args: --format "{distance}"
+ env-var: DISTANCE
- - name: Check if the container should be built
+ - name: Distance
+ run: echo $DISTANCE
+
+ - name: Check if the container should be built (distance from git tag is 0, or force build)
id: builder
env:
- RUN: ${{ toJSON(inputs.build || !contains(steps.version.outputs.version, '-')) }}
+ RUN: ${{ toJSON(inputs.build || env.DISTANCE == "0" }}
run: |
echo "run=$RUN" >> $GITHUB_OUTPUT
echo "Run build: $RUN"
- - name: Set the version in pyproject.toml (workaround for uv not supporting dynamic version)
- if: fromJSON(steps.builder.outputs.run)
- env:
- VERSION: ${{ steps.version.outputs.version }}
- run: sed "s/0.0.0/$VERSION/" -i pyproject.toml
-
- name: Workaround for bug of podman-login
if: fromJSON(steps.builder.outputs.run)
run: |
From 6f060dc2bfd726eec57607ef7ca911de22aa4177 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 02:26:37 +0100
Subject: [PATCH 57/70] CI: bump uv
---
.forgejo/workflows/build.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml
index 9f21750..18a89ee 100644
--- a/.forgejo/workflows/build.yaml
+++ b/.forgejo/workflows/build.yaml
@@ -19,7 +19,7 @@ jobs:
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v4
with:
- version: "0.5.16"
+ version: "0.6.3"
- name: Install
run: uv sync
From 22d0a9852c3c97ffeac9a6cdbff5e4f31ee6e6a0 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 03:04:14 +0100
Subject: [PATCH 58/70] CI: not use dunamai github action as it uses plain pip,
not uv pip
---
.forgejo/workflows/build.yaml | 10 ++--------
1 file changed, 2 insertions(+), 8 deletions(-)
diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml
index 18a89ee..8284c15 100644
--- a/.forgejo/workflows/build.yaml
+++ b/.forgejo/workflows/build.yaml
@@ -28,19 +28,13 @@ jobs:
run: .venv/bin/pytest -s tests/basic.py
- name: Get version
- uses: mtkennerly/dunamai-action@v1
- with:
- args: --style semver
- env-var: VERSION
+ run: echo "VERSION=$(.venv/bin/dunamai --style semver)" >> $GITHUB_ENV
- name: Version
run: echo $VERSION
- name: Get distance from tag
- uses: mtkennerly/dunamai-action@v1
- with:
- args: --format "{distance}"
- env-var: DISTANCE
+ run: echo "DISTANCE=$(.venv/bin/dunamai --format '{distance}')" >> $GITHUB_ENV
- name: Distance
run: echo $DISTANCE
From 9f7b0902739534127c9e5890b40c9be7e42dfac7 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 03:12:46 +0100
Subject: [PATCH 59/70] CI: WIP
---
.forgejo/workflows/build.yaml | 6 +++---
.forgejo/workflows/test.yaml | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml
index 8284c15..645d5d2 100644
--- a/.forgejo/workflows/build.yaml
+++ b/.forgejo/workflows/build.yaml
@@ -28,13 +28,13 @@ jobs:
run: .venv/bin/pytest -s tests/basic.py
- name: Get version
- run: echo "VERSION=$(.venv/bin/dunamai --style semver)" >> $GITHUB_ENV
+ run: echo "VERSION=$(.venv/bin/dunamai from any --style semver)" >> $GITHUB_ENV
- name: Version
run: echo $VERSION
- name: Get distance from tag
- run: echo "DISTANCE=$(.venv/bin/dunamai --format '{distance}')" >> $GITHUB_ENV
+ run: echo "DISTANCE=$(.venv/bin/dunamai from any --format '{distance}')" >> $GITHUB_ENV
- name: Distance
run: echo $DISTANCE
@@ -42,7 +42,7 @@ jobs:
- name: Check if the container should be built (distance from git tag is 0, or force build)
id: builder
env:
- RUN: ${{ toJSON(inputs.build || env.DISTANCE == "0" }}
+ RUN: ${{ toJSON(inputs.build || env.DISTANCE == "0") }}
run: |
echo "run=$RUN" >> $GITHUB_OUTPUT
echo "Run build: $RUN"
diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml
index a56a9ce..f4d994e 100644
--- a/.forgejo/workflows/test.yaml
+++ b/.forgejo/workflows/test.yaml
@@ -19,7 +19,7 @@ jobs:
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v4
with:
- version: "0.5.16"
+ version: "0.6.3"
- name: Install
run: uv sync
From 821df027587eaee24b3db792033266564f66c62c Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 04:28:04 +0100
Subject: [PATCH 60/70] CI: WIP
---
.forgejo/workflows/build.yaml | 32 +++++++++++---------------------
1 file changed, 11 insertions(+), 21 deletions(-)
diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml
index 645d5d2..cf079d9 100644
--- a/.forgejo/workflows/build.yaml
+++ b/.forgejo/workflows/build.yaml
@@ -28,33 +28,23 @@ jobs:
run: .venv/bin/pytest -s tests/basic.py
- name: Get version
- run: echo "VERSION=$(.venv/bin/dunamai from any --style semver)" >> $GITHUB_ENV
-
- - name: Version
- run: echo $VERSION
+ run: |
+ echo "VERSION=$(.venv/bin/dunamai from any --style semver)" >> $GITHUB_ENV
+ echo $VERSION
- name: Get distance from tag
- run: echo "DISTANCE=$(.venv/bin/dunamai from any --format '{distance}')" >> $GITHUB_ENV
-
- - name: Distance
- run: echo $DISTANCE
-
- - name: Check if the container should be built (distance from git tag is 0, or force build)
- id: builder
- env:
- RUN: ${{ toJSON(inputs.build || env.DISTANCE == "0") }}
run: |
- echo "run=$RUN" >> $GITHUB_OUTPUT
- echo "Run build: $RUN"
+ echo "DISTANCE=$(.venv/bin/dunamai from any --format '{distance}')" >> $GITHUB_ENV
+ echo $DISTANCE
- name: Workaround for bug of podman-login
- if: fromJSON(steps.builder.outputs.run)
+ if: env.DISTANCE == '0'
run: |
mkdir -p $HOME/.docker
echo "{ \"auths\": {} }" > $HOME/.docker/config.json
- name: Log in to the container registry (with another workaround)
- if: fromJSON(steps.builder.outputs.run)
+ if: env.DISTANCE == '0'
uses: actions/podman-login@v1
with:
registry: ${{ vars.REGISTRY }}
@@ -63,7 +53,7 @@ jobs:
auth_file_path: /tmp/auth.json
- name: Build the container image
- if: fromJSON(steps.builder.outputs.run)
+ if: env.DISTANCE == '0'
uses: actions/buildah-build@v1
with:
image: oidc-fastapi-test
@@ -74,7 +64,7 @@ jobs:
./Containerfile
- name: Push the image to the registry
- if: fromJSON(steps.builder.outputs.run)
+ if: env.DISTANCE == '0'
uses: actions/push-to-registry@v2
with:
registry: "docker://${{ vars.REGISTRY }}/${{ vars.ORGANISATION }}"
@@ -82,11 +72,11 @@ jobs:
tags: latest ${{ steps.version.outputs.version }}
- name: Build wheel
- if: fromJSON(steps.builder.outputs.run)
+ if: env.DISTANCE == '0'
run: uv build --wheel
- name: Publish Python package (home)
- if: fromJSON(steps.builder.outputs.run)
+ if: env.DISTANCE == '0'
env:
LOCAL_PYPI_TOKEN: ${{ secrets.LOCAL_PYPI_TOKEN }}
run: uv publish --publish-url https://code.philo.ydns.eu/api/packages/philorg/pypi --token $LOCAL_PYPI_TOKEN
From c5b1bdeda92d885749c38360a81572f2ef147087 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 04:31:31 +0100
Subject: [PATCH 61/70] CI: WIP
---
.forgejo/workflows/build.yaml | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml
index cf079d9..06c8be1 100644
--- a/.forgejo/workflows/build.yaml
+++ b/.forgejo/workflows/build.yaml
@@ -28,14 +28,16 @@ jobs:
run: .venv/bin/pytest -s tests/basic.py
- name: Get version
- run: |
- echo "VERSION=$(.venv/bin/dunamai from any --style semver)" >> $GITHUB_ENV
- echo $VERSION
+ run: echo "VERSION=$(.venv/bin/dunamai from any --style semver)" >> $GITHUB_ENV
+
+ - name: Version
+ run: echo $VERSION
- name: Get distance from tag
- run: |
- echo "DISTANCE=$(.venv/bin/dunamai from any --format '{distance}')" >> $GITHUB_ENV
- echo $DISTANCE
+ run: echo "DISTANCE=$(.venv/bin/dunamai from any --format '{distance}')" >> $GITHUB_ENV
+
+ - name: Distance
+ run: echo $DISTANCE
- name: Workaround for bug of podman-login
if: env.DISTANCE == '0'
From c3ebad42d575b91d221f13a3b93d9cbbbcd80af8 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 04:34:19 +0100
Subject: [PATCH 62/70] CI: WIP
---
.forgejo/workflows/build.yaml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml
index 06c8be1..e617190 100644
--- a/.forgejo/workflows/build.yaml
+++ b/.forgejo/workflows/build.yaml
@@ -61,7 +61,7 @@ jobs:
image: oidc-fastapi-test
oci: true
labels: oidc-fastapi-test
- tags: latest ${{ steps.version.outputs.version }}
+ tags: latest env.VERSION
containerfiles: |
./Containerfile
@@ -71,7 +71,7 @@ jobs:
with:
registry: "docker://${{ vars.REGISTRY }}/${{ vars.ORGANISATION }}"
image: oidc-fastapi-test
- tags: latest ${{ steps.version.outputs.version }}
+ tags: latest env.VERSION
- name: Build wheel
if: env.DISTANCE == '0'
From 4355e6dc423b0db8ac5ca4746287dc20fd8c8d39 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 12:30:23 +0100
Subject: [PATCH 63/70] CI: WIP
---
Containerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Containerfile b/Containerfile
index 2e3fd28..0ec45d1 100644
--- a/Containerfile
+++ b/Containerfile
@@ -1,4 +1,4 @@
-FROM docker.io/library/python:alpine
+FROM docker.io/library/python:latest
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
From b01f2332086a08b4ad7c619d8fcb1a67e9a8e99c Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 25 Feb 2025 18:34:52 +0100
Subject: [PATCH 64/70] Add log messages for debugging connection to auth
server
---
src/oidc_test/auth/provider.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/oidc_test/auth/provider.py b/src/oidc_test/auth/provider.py
index c614805..ce288a6 100644
--- a/src/oidc_test/auth/provider.py
+++ b/src/oidc_test/auth/provider.py
@@ -61,28 +61,34 @@ class Provider(AuthProviderSettings):
if self.info_url is not None:
try:
provider_info = await client.get(self.info_url)
- except Exception:
+ except Exception as err:
+ logger.debug("Provider_info: cannot connect")
+ logger.exception(err)
raise NoPublicKey
try:
self.info = provider_info.json()
except JSONDecodeError:
+ logger.debug("Provider_info: cannot decode json response")
raise NoPublicKey
if "public_key" in self.info:
# For Keycloak
try:
public_key = str(self.info["public_key"])
except KeyError:
+ logger.debug("Provider_info: cannot get public_key")
raise NoPublicKey
elif "keys" in self.info:
# For Forgejo/Gitea
try:
public_key = str(self.info["keys"][0]["n"])
except KeyError:
+ logger.debug("Provider_info: cannot get key 0.n")
raise NoPublicKey
if self.public_key_url is not None:
resp = await client.get(self.public_key_url)
public_key = resp.text
if public_key is None:
+ logger.debug("Provider_info: cannot determine public key")
raise NoPublicKey
self.public_key = "\n".join(
["-----BEGIN PUBLIC KEY-----", public_key, "-----END PUBLIC KEY-----"]
From 8b3a339196d92aa36dd17480af0586783bf1121e Mon Sep 17 00:00:00 2001
From: phil
Date: Sat, 22 Mar 2025 01:01:32 +0100
Subject: [PATCH 65/70] CI: fix container tag
---
.forgejo/workflows/build.yaml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml
index e617190..379aaa8 100644
--- a/.forgejo/workflows/build.yaml
+++ b/.forgejo/workflows/build.yaml
@@ -19,7 +19,7 @@ jobs:
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v4
with:
- version: "0.6.3"
+ version: "0.6.9"
- name: Install
run: uv sync
@@ -61,7 +61,7 @@ jobs:
image: oidc-fastapi-test
oci: true
labels: oidc-fastapi-test
- tags: latest env.VERSION
+ tags: "latest ${{ env.VERSION }}"
containerfiles: |
./Containerfile
@@ -71,7 +71,7 @@ jobs:
with:
registry: "docker://${{ vars.REGISTRY }}/${{ vars.ORGANISATION }}"
image: oidc-fastapi-test
- tags: latest env.VERSION
+ tags: "latest ${{ env.VERSION }}"
- name: Build wheel
if: env.DISTANCE == '0'
From 0e6ff98f03a83cea53606607ba3240ddbc413ec0 Mon Sep 17 00:00:00 2001
From: phil
Date: Tue, 27 May 2025 16:44:13 +0200
Subject: [PATCH 66/70] CI: use slim base image
---
Containerfile | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/Containerfile b/Containerfile
index 0ec45d1..357cc84 100644
--- a/Containerfile
+++ b/Containerfile
@@ -1,6 +1,7 @@
-FROM docker.io/library/python:latest
+FROM docker.io/library/python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
+COPY --from=docker.io/python:3.13 /usr/bin/git /usr/local/bin/git
COPY . /app
From 1b6d754855d2f539e822e24e5eff10e385bfd7ea Mon Sep 17 00:00:00 2001
From: phil
Date: Fri, 27 Jun 2025 17:11:35 +0200
Subject: [PATCH 67/70] CI: switch to woodpecker
---
.forgejo/workflows/build.yaml | 85 -----------------------------------
.forgejo/workflows/test.yaml | 28 ------------
.woodpecker/build.yaml | 66 +++++++++++++++++++++++++++
.woodpecker/test.yaml | 21 +++++++++
src/oidc_test/__init__.py | 2 +-
5 files changed, 88 insertions(+), 114 deletions(-)
delete mode 100644 .forgejo/workflows/build.yaml
delete mode 100644 .forgejo/workflows/test.yaml
create mode 100644 .woodpecker/build.yaml
create mode 100644 .woodpecker/test.yaml
diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml
deleted file mode 100644
index 379aaa8..0000000
--- a/.forgejo/workflows/build.yaml
+++ /dev/null
@@ -1,85 +0,0 @@
-on:
- push:
- workflow_dispatch:
- inputs:
- verbose:
- description: "Verbose"
- required: false
- default: false
- type: boolean
-
-jobs:
- build:
- runs-on: container
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Install the latest version of uv
- uses: astral-sh/setup-uv@v4
- with:
- version: "0.6.9"
-
- - name: Install
- run: uv sync
-
- - name: Run tests (API call)
- run: .venv/bin/pytest -s tests/basic.py
-
- - name: Get version
- run: echo "VERSION=$(.venv/bin/dunamai from any --style semver)" >> $GITHUB_ENV
-
- - name: Version
- run: echo $VERSION
-
- - name: Get distance from tag
- run: echo "DISTANCE=$(.venv/bin/dunamai from any --format '{distance}')" >> $GITHUB_ENV
-
- - name: Distance
- run: echo $DISTANCE
-
- - name: Workaround for bug of podman-login
- if: env.DISTANCE == '0'
- run: |
- mkdir -p $HOME/.docker
- echo "{ \"auths\": {} }" > $HOME/.docker/config.json
-
- - name: Log in to the container registry (with another workaround)
- if: env.DISTANCE == '0'
- uses: actions/podman-login@v1
- with:
- registry: ${{ vars.REGISTRY }}
- username: ${{ secrets.REGISTRY_USER }}
- password: ${{ secrets.REGISTRY_PASSWORD }}
- auth_file_path: /tmp/auth.json
-
- - name: Build the container image
- if: env.DISTANCE == '0'
- uses: actions/buildah-build@v1
- with:
- image: oidc-fastapi-test
- oci: true
- labels: oidc-fastapi-test
- tags: "latest ${{ env.VERSION }}"
- containerfiles: |
- ./Containerfile
-
- - name: Push the image to the registry
- if: env.DISTANCE == '0'
- uses: actions/push-to-registry@v2
- with:
- registry: "docker://${{ vars.REGISTRY }}/${{ vars.ORGANISATION }}"
- image: oidc-fastapi-test
- tags: "latest ${{ env.VERSION }}"
-
- - name: Build wheel
- if: env.DISTANCE == '0'
- run: uv build --wheel
-
- - name: Publish Python package (home)
- if: env.DISTANCE == '0'
- env:
- LOCAL_PYPI_TOKEN: ${{ secrets.LOCAL_PYPI_TOKEN }}
- run: uv publish --publish-url https://code.philo.ydns.eu/api/packages/philorg/pypi --token $LOCAL_PYPI_TOKEN
- continue-on-error: true
diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml
deleted file mode 100644
index f4d994e..0000000
--- a/.forgejo/workflows/test.yaml
+++ /dev/null
@@ -1,28 +0,0 @@
-on:
- push:
- workflow_dispatch:
- inputs:
- verbose:
- description: "Verbose"
- required: false
- default: false
- type: boolean
-
-jobs:
- test:
- runs-on: container
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Install the latest version of uv
- uses: astral-sh/setup-uv@v4
- with:
- version: "0.6.3"
-
- - name: Install
- run: uv sync
-
- - name: Run tests (API call)
- run: .venv/bin/pytest -s tests/basic.py
diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml
new file mode 100644
index 0000000..6cdc17d
--- /dev/null
+++ b/.woodpecker/build.yaml
@@ -0,0 +1,66 @@
+when:
+ - event: manual
+ - event: tag
+
+depends_on:
+ - test
+
+steps:
+ python_sync:
+ image: code.philo.ydns.eu/philorg/uv
+ volumes:
+ - uv-cache:/uv-cache
+ environment:
+ UV_CACHE_DIR: /uv-cache
+ UV_LINK_MODE: copy
+ commands:
+ - uv sync
+
+ python_build:
+ image: code.philo.ydns.eu/philorg/uv
+ volumes:
+ - uv-cache:/uv-cache
+ environment:
+ UV_CACHE_DIR: /uv-cache
+ UV_LINK_MODE: copy
+ commands:
+ - uv build --wheel
+ - uv cache prune --ci
+
+ python_publish:
+ image: code.philo.ydns.eu/philorg/uv
+ environment:
+ OWNER: philorg
+ REGISTRY_URL: https://code.philo.ydns.eu
+ REGISTRY_TOKEN:
+ from_secret: registry_token
+ commands:
+ - uv publish --publish-url $REGISTRY_URL/api/packages/$OWNER/pypi --token $REGISTRY_TOKEN dist/*.whl
+ failure: ignore
+
+ container_build_publish:
+ image: quay.io/podman/stable:latest
+ # Caution: This image is built daily. It might fill up your image store quickly.
+ #pull: true
+ volumes:
+ - containers:/var/lib/containers
+ - uv:/root/.cache/uv
+ # Fill in the trusted checkbox in Woodpecker's settings as well
+ privileged: true
+ environment:
+ registry: code.philo.ydns.eu
+ org: philorg
+ container_name: oidc-fastapi-test
+ registry_token:
+ from_secret: registry_token
+ commands:
+ # Login at the registry
+ - podman login -u __token__ --password $registry_token $registry
+ # Build the container image
+ - podman build --volume=/var/lib/containers:/var/lib/containers --tag $registry/$org/$container_name:latest --tag $registry/$org/$container_name:$CI_COMMIT_TAG .
+ # Push the image
+ - podman push $registry/$org/$container_name:latest
+ - podman push $registry/$org/$container_name:$CI_COMMIT_TAG
+ - podman build --volume=/var/lib/containers:/var/lib/containers --tag $registry/$org/$container_name:latest-full --tag $registry/$org/$container_name:$CI_COMMIT_TAG-full -f Containerfile.with_plugins
+ - podman push $registry/$org/$container_name:latest-full
+ - podman push $registry/$org/$container_name:$CI_COMMIT_TAG-full
diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml
new file mode 100644
index 0000000..a02e51d
--- /dev/null
+++ b/.woodpecker/test.yaml
@@ -0,0 +1,21 @@
+when:
+ - event: push
+ branch: main
+ - event: manual
+ - event: tag
+
+steps:
+ sync:
+ image: code.philo.ydns.eu/philorg/uv
+ volumes:
+ - uv-cache:/uv-cache
+ environment:
+ UV_CACHE_DIR: /uv-cache
+ UV_LINK_MODE: copy
+ commands:
+ - uv sync
+
+ test:
+ image: pyton:alpine
+ commands:
+ - .venv/bin/pytest -s tests/basic.py
diff --git a/src/oidc_test/__init__.py b/src/oidc_test/__init__.py
index f449e2b..f154022 100644
--- a/src/oidc_test/__init__.py
+++ b/src/oidc_test/__init__.py
@@ -4,7 +4,7 @@ try:
from dunamai import Version, Style
__version__ = Version.from_git().serialize(style=Style.SemVer, dirty=True)
-except ImportError:
+except (ImportError, RuntimeError):
# __name__ could be used if the package name is the same
# as the directory - not the case here
# __version__ = importlib.metadata.version(__name__)
From 1dec3988d5a83294d4f7d77942d603d6aedf0154 Mon Sep 17 00:00:00 2001
From: phil
Date: Fri, 27 Jun 2025 17:15:16 +0200
Subject: [PATCH 68/70] CI: fix test
---
.woodpecker/test.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml
index a02e51d..d8816b2 100644
--- a/.woodpecker/test.yaml
+++ b/.woodpecker/test.yaml
@@ -16,6 +16,6 @@ steps:
- uv sync
test:
- image: pyton:alpine
+ image: code.philo.ydns.eu/philorg/uv
commands:
- .venv/bin/pytest -s tests/basic.py
From b14e6a0be5eadfe49a71ff12233f892f23fa7d44 Mon Sep 17 00:00:00 2001
From: phil
Date: Fri, 27 Jun 2025 17:21:43 +0200
Subject: [PATCH 69/70] CI: fix build
---
.woodpecker/build.yaml | 3 ---
1 file changed, 3 deletions(-)
diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml
index 6cdc17d..3af8132 100644
--- a/.woodpecker/build.yaml
+++ b/.woodpecker/build.yaml
@@ -61,6 +61,3 @@ steps:
# Push the image
- podman push $registry/$org/$container_name:latest
- podman push $registry/$org/$container_name:$CI_COMMIT_TAG
- - podman build --volume=/var/lib/containers:/var/lib/containers --tag $registry/$org/$container_name:latest-full --tag $registry/$org/$container_name:$CI_COMMIT_TAG-full -f Containerfile.with_plugins
- - podman push $registry/$org/$container_name:latest-full
- - podman push $registry/$org/$container_name:$CI_COMMIT_TAG-full
From e1e755fde5075b714a1df6b967a5c32cc966e752 Mon Sep 17 00:00:00 2001
From: phil
Date: Fri, 27 Jun 2025 17:23:28 +0200
Subject: [PATCH 70/70] Update README
---
README.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/README.md b/README.md
index 68f335d..c7f8741 100644
--- a/README.md
+++ b/README.md
@@ -127,3 +127,5 @@ with the setting file in the current working directory:
```sh
podman run -p 8000:80 --env OIDC_TEST_CONFIG_FILE=/app/settings.yaml --mount type=bind,source=settings.yaml,destination=/app/settings.yaml code.philo.ydns.eu/philorg/oidc-fastapi-test:latest
```
+
+[](https://code.philo.ydns.eu/woodpecker/repos/22)