Renamed OIDC permissions claim to more commonly used groups. Move jwtk to a more convenient location. Draft admin app OIDC client configuratioon.

This commit is contained in:
Leo Vasanko
2026-02-16 16:54:53 +00:00
parent eece6d4a21
commit b73b2d6fe9
11 changed files with 295 additions and 22 deletions
+1
View File
@@ -148,5 +148,6 @@ __all__ = [
"update_user_theme",
# OIDC
"create_oid_client",
"update_oid_client",
"delete_oid_client",
]
+36
View File
@@ -610,6 +610,42 @@ def create_oid_client(client: OIDClient, *, ctx: SessionContext | None = None) -
_db.oid_clients[client.uuid] = client
def update_oid_client(
client_uuid: UUID,
name: str | None = None,
redirect_uris: list[str] | None = None,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update an OIDC client's name and/or redirect URIs."""
if client_uuid not in _db.oid_clients:
raise ValueError(f"OIDC client {client_uuid} not found")
client = _db.oid_clients[client_uuid]
changes = {}
if name is not None and name != client.name:
changes["name"] = name
if redirect_uris is not None and redirect_uris != client.redirect_uris:
changes["redirect_uris"] = redirect_uris
if not changes:
return # No changes to make
with _db.transaction("admin:update_oid_client", ctx):
# Create updated client with new values
updated_client = OIDClient(
client_secret_hash=client.client_secret_hash,
name=name if name is not None else client.name,
redirect_uris=redirect_uris
if redirect_uris is not None
else client.redirect_uris,
created_at=client.created_at,
)
updated_client.uuid = client.uuid
_db.oid_clients[client_uuid] = updated_client
def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete an OIDC client."""
if client_uuid not in _db.oid_clients:
-2
View File
@@ -541,7 +541,6 @@ class OIDClient(msgspec.Struct, dict=True):
client_secret_hash: bytes
name: str
redirect_uris: list[str]
created_at: datetime
def __post_init__(self):
if not hasattr(self, "uuid"):
@@ -565,7 +564,6 @@ class OIDClient(msgspec.Struct, dict=True):
client_secret_hash=secret_hash,
name=name,
redirect_uris=redirect_uris,
created_at=now,
)
client.uuid = uuid7.create(now)
return client, client_secret
+47 -2
View File
@@ -942,14 +942,13 @@ async def admin_list_oidc_clients(request: Request, auth=AUTH_COOKIE):
mode="forbidden",
)
clients = db.data().oid_clients.values()
clients = sorted(db.data().oid_clients.values(), key=lambda c: c.uuid)
return MsgspecResponse(
[
{
"uuid": str(client.uuid),
"name": client.name,
"redirect_uris": client.redirect_uris,
"created_at": format_datetime(client.created_at),
}
for client in clients
]
@@ -1012,6 +1011,52 @@ async def admin_create_oidc_client(
}
@app.patch("/oidc-clients/{client_uuid}")
async def admin_update_oidc_client(
client_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update an OIDC client's name and redirect URIs (master admin only)."""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
name = payload.get("name", "").strip() if "name" in payload else None
redirect_uris = payload.get("redirect_uris") if "redirect_uris" in payload else None
if name is not None and not name:
raise ValueError("Client name cannot be empty")
if redirect_uris is not None:
if not isinstance(redirect_uris, list) or not redirect_uris:
raise ValueError("At least one redirect URI is required")
# Validate redirect URIs
for uri in redirect_uris:
if not isinstance(uri, str) or not uri.startswith("http"):
raise ValueError(f"Invalid redirect URI: {uri}")
try:
db.update_oid_client(
client_uuid, name=name, redirect_uris=redirect_uris, ctx=ctx
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "ok"}
@app.delete("/oidc-clients/{client_uuid}")
async def admin_delete_oidc_client(
client_uuid: UUID,
+1 -8
View File
@@ -16,7 +16,6 @@ from paskia.fastapi.__main__ import DEVMODE
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil, passphrase, vitedev
from paskia.util.oidjwt import get_jwks
# Configure custom logging
configure_access_logging()
@@ -106,7 +105,7 @@ async def openid_configuration(request: Request):
"authorization_endpoint": f"{issuer}/auth/restricted/oidc",
"token_endpoint": f"{issuer}/auth/oidc/token",
"userinfo_endpoint": f"{issuer}/auth/oidc/userinfo",
"jwks_uri": f"{issuer}/.well-known/jwks.json",
"jwks_uri": f"{issuer}/auth/oidc/keys",
"backchannel_logout_supported": True,
"backchannel_logout_session_supported": True,
"backchannel_logout_uri": f"{issuer}/auth/oidc/backchannel-logout",
@@ -131,12 +130,6 @@ async def openid_configuration(request: Request):
}
@app.get("/.well-known/jwks.json")
async def jwks():
"""JSON Web Key Set for token verification."""
return get_jwks()
@app.get("/auth/restricted/iframe")
@app.get("/auth/restricted/oidc")
async def restricted_view():
+7 -1
View File
@@ -31,6 +31,12 @@ _logger = logging.getLogger(__name__)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@app.get("/keys")
async def keys():
"""JSON Web Key Set for token verification."""
return oidjwt.get_jwks()
def _oidc_session_by_token(
token: str, client_uuid: UUID | None = None
) -> Session | None:
@@ -353,7 +359,7 @@ def _build_token_response(
name=user.display_name,
preferred_username=user.preferred_username,
email=user.email,
permissions=permissions if permissions else None,
groups=permissions if permissions else None,
auth_time=auth_time,
)
+4 -4
View File
@@ -90,7 +90,7 @@ def create_id_token(
name: str | None = None,
preferred_username: str | None = None,
email: str | None = None,
permissions: list[str] | None = None,
groups: list[str] | None = None,
auth_time: datetime | None = None,
expires_in: int = 3600,
) -> str:
@@ -105,7 +105,7 @@ def create_id_token(
name: User's display name
preferred_username: User's preferred username
email: User's email address
permissions: List of permission scopes
groups: List of permission scopes (groups claim)
auth_time: When the user authenticated (last credential use time)
expires_in: Token lifetime in seconds
@@ -131,8 +131,8 @@ def create_id_token(
payload["preferred_username"] = preferred_username
if email:
payload["email"] = email
if permissions:
payload["permissions"] = permissions
if groups:
payload["groups"] = groups
if auth_time:
payload["auth_time"] = int(auth_time.timestamp())