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:
@@ -148,5 +148,6 @@ __all__ = [
|
||||
"update_user_theme",
|
||||
# OIDC
|
||||
"create_oid_client",
|
||||
"update_oid_client",
|
||||
"delete_oid_client",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user