Implement backchannel logout to client.
This commit is contained in:
@@ -27,8 +27,12 @@ def cleanup_expired() -> int:
|
|||||||
"""Remove expired sessions and reset tokens. Returns count removed."""
|
"""Remove expired sessions and reset tokens. Returns count removed."""
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
count = 0
|
count = 0
|
||||||
|
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.expiry < now]
|
||||||
|
if expired_sessions:
|
||||||
|
from paskia import oidc_notify # noqa: PLC0415
|
||||||
|
|
||||||
|
oidc_notify.schedule_notifications(expired_sessions)
|
||||||
with _ops._db.transaction("expiry"):
|
with _ops._db.transaction("expiry"):
|
||||||
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.expiry < now]
|
|
||||||
for k in expired_sessions:
|
for k in expired_sessions:
|
||||||
del _ops._db.sessions[k]
|
del _ops._db.sessions[k]
|
||||||
count += 1
|
count += 1
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ from paskia.util.crypto import hash_secret
|
|||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Sentinel for distinguishing "not provided" from None
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
# Global database instance (empty until init() loads data)
|
# Global database instance (empty until init() loads data)
|
||||||
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
||||||
_store = JsonlStore(_db)
|
_store = JsonlStore(_db)
|
||||||
@@ -395,6 +398,9 @@ def delete_session(
|
|||||||
"""
|
"""
|
||||||
if key not in _db.sessions:
|
if key not in _db.sessions:
|
||||||
raise ValueError("Session not found")
|
raise ValueError("Session not found")
|
||||||
|
from paskia import oidc_notify # noqa: PLC0415
|
||||||
|
|
||||||
|
oidc_notify.schedule_notifications([key])
|
||||||
with _db.transaction(action, ctx):
|
with _db.transaction(action, ctx):
|
||||||
_db.sessions[key].delete()
|
_db.sessions[key].delete()
|
||||||
|
|
||||||
@@ -411,6 +417,10 @@ def delete_sessions_for_user(
|
|||||||
user = _db.users.get(user_uuid)
|
user = _db.users.get(user_uuid)
|
||||||
if not user:
|
if not user:
|
||||||
return
|
return
|
||||||
|
from paskia import oidc_notify # noqa: PLC0415
|
||||||
|
|
||||||
|
keys = [s.key for s in user.sessions]
|
||||||
|
oidc_notify.schedule_notifications(keys)
|
||||||
with _db.transaction("admin:delete_sessions_for_user", ctx):
|
with _db.transaction("admin:delete_sessions_for_user", ctx):
|
||||||
for sess in user.sessions:
|
for sess in user.sessions:
|
||||||
sess.delete()
|
sess.delete()
|
||||||
@@ -615,6 +625,7 @@ def update_oid_client(
|
|||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
redirect_uris: list[str] | None = None,
|
redirect_uris: list[str] | None = None,
|
||||||
secret_hash: bytes | None = None,
|
secret_hash: bytes | None = None,
|
||||||
|
backchannel_logout_uri: str | None = _UNSET,
|
||||||
*,
|
*,
|
||||||
ctx: SessionContext | None = None,
|
ctx: SessionContext | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -631,10 +642,18 @@ def update_oid_client(
|
|||||||
changes["redirect_uris"] = redirect_uris
|
changes["redirect_uris"] = redirect_uris
|
||||||
if secret_hash is not None and secret_hash != client.client_secret_hash:
|
if secret_hash is not None and secret_hash != client.client_secret_hash:
|
||||||
changes["client_secret_hash"] = secret_hash
|
changes["client_secret_hash"] = secret_hash
|
||||||
|
if backchannel_logout_uri is not _UNSET and backchannel_logout_uri != client.backchannel_logout_uri:
|
||||||
|
changes["backchannel_logout_uri"] = backchannel_logout_uri
|
||||||
|
|
||||||
if not changes:
|
if not changes:
|
||||||
return # No changes to make
|
return # No changes to make
|
||||||
|
|
||||||
|
new_logout_uri = (
|
||||||
|
backchannel_logout_uri
|
||||||
|
if backchannel_logout_uri is not _UNSET
|
||||||
|
else client.backchannel_logout_uri
|
||||||
|
)
|
||||||
|
|
||||||
with _db.transaction("admin:update_oid_client", ctx):
|
with _db.transaction("admin:update_oid_client", ctx):
|
||||||
# Create updated client with new values
|
# Create updated client with new values
|
||||||
updated_client = OIDClient(
|
updated_client = OIDClient(
|
||||||
@@ -645,6 +664,7 @@ def update_oid_client(
|
|||||||
redirect_uris=redirect_uris
|
redirect_uris=redirect_uris
|
||||||
if redirect_uris is not None
|
if redirect_uris is not None
|
||||||
else client.redirect_uris,
|
else client.redirect_uris,
|
||||||
|
backchannel_logout_uri=new_logout_uri,
|
||||||
)
|
)
|
||||||
updated_client.uuid = client.uuid
|
updated_client.uuid = client.uuid
|
||||||
_db.oid_clients[client_uuid] = updated_client
|
_db.oid_clients[client_uuid] = updated_client
|
||||||
@@ -665,6 +685,7 @@ def reset_oid_client_secret(
|
|||||||
client_secret_hash=new_secret_hash,
|
client_secret_hash=new_secret_hash,
|
||||||
name=client.name,
|
name=client.name,
|
||||||
redirect_uris=client.redirect_uris,
|
redirect_uris=client.redirect_uris,
|
||||||
|
backchannel_logout_uri=client.backchannel_logout_uri,
|
||||||
)
|
)
|
||||||
updated.uuid = client.uuid
|
updated.uuid = client.uuid
|
||||||
_db.oid_clients[client_uuid] = updated
|
_db.oid_clients[client_uuid] = updated
|
||||||
|
|||||||
@@ -536,7 +536,7 @@ class ResetToken(msgspec.Struct, dict=True):
|
|||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class OIDClient(msgspec.Struct, dict=True):
|
class OIDClient(msgspec.Struct, dict=True, omit_defaults=True):
|
||||||
"""OIDC client (relying party) registration.
|
"""OIDC client (relying party) registration.
|
||||||
|
|
||||||
client_id is the dict key (UUID).
|
client_id is the dict key (UUID).
|
||||||
@@ -545,6 +545,7 @@ class OIDClient(msgspec.Struct, dict=True):
|
|||||||
client_secret_hash: bytes
|
client_secret_hash: bytes
|
||||||
name: str
|
name: str
|
||||||
redirect_uris: list[str]
|
redirect_uris: list[str]
|
||||||
|
backchannel_logout_uri: str | None = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if not hasattr(self, "uuid"):
|
if not hasattr(self, "uuid"):
|
||||||
@@ -557,6 +558,7 @@ class OIDClient(msgspec.Struct, dict=True):
|
|||||||
redirect_uris: list[str],
|
redirect_uris: list[str],
|
||||||
client_secret: str,
|
client_secret: str,
|
||||||
created_at: datetime | None = None,
|
created_at: datetime | None = None,
|
||||||
|
backchannel_logout_uri: str | None = None,
|
||||||
) -> tuple[OIDClient, str]:
|
) -> tuple[OIDClient, str]:
|
||||||
"""Create a new OIDClient with hashed secret.
|
"""Create a new OIDClient with hashed secret.
|
||||||
|
|
||||||
@@ -568,6 +570,7 @@ class OIDClient(msgspec.Struct, dict=True):
|
|||||||
client_secret_hash=secret_hash,
|
client_secret_hash=secret_hash,
|
||||||
name=name,
|
name=name,
|
||||||
redirect_uris=redirect_uris,
|
redirect_uris=redirect_uris,
|
||||||
|
backchannel_logout_uri=backchannel_logout_uri,
|
||||||
)
|
)
|
||||||
client.uuid = uuid7.create(now)
|
client.uuid = uuid7.create(now)
|
||||||
return client, client_secret
|
return client, client_secret
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from paskia.db import Org as OrgDC
|
|||||||
from paskia.db import Permission as PermDC
|
from paskia.db import Permission as PermDC
|
||||||
from paskia.db import Role as RoleDC
|
from paskia.db import Role as RoleDC
|
||||||
from paskia.db import User as UserDC
|
from paskia.db import User as UserDC
|
||||||
|
from paskia.db.operations import _UNSET
|
||||||
from paskia.db.structs import OIDClient
|
from paskia.db.structs import OIDClient
|
||||||
from paskia.fastapi import authz
|
from paskia.fastapi import authz
|
||||||
from paskia.fastapi.response import MsgspecResponse
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
@@ -966,6 +967,7 @@ async def admin_list_oidc_clients(request: Request, auth=AUTH_COOKIE):
|
|||||||
"uuid": str(client.uuid),
|
"uuid": str(client.uuid),
|
||||||
"name": client.name,
|
"name": client.name,
|
||||||
"redirect_uris": client.redirect_uris,
|
"redirect_uris": client.redirect_uris,
|
||||||
|
"backchannel_logout_uri": client.backchannel_logout_uri,
|
||||||
"active_sessions": client_session_counts.get(client.uuid, 0),
|
"active_sessions": client_session_counts.get(client.uuid, 0),
|
||||||
}
|
}
|
||||||
for client in clients
|
for client in clients
|
||||||
@@ -999,6 +1001,9 @@ async def admin_create_oidc_client(
|
|||||||
secret_hash_hex = payload.get("secret_hash", "").strip()
|
secret_hash_hex = payload.get("secret_hash", "").strip()
|
||||||
name = payload.get("name", "").strip()
|
name = payload.get("name", "").strip()
|
||||||
redirect_uris = payload.get("redirect_uris", [])
|
redirect_uris = payload.get("redirect_uris", [])
|
||||||
|
backchannel_logout_uri = payload.get("backchannel_logout_uri")
|
||||||
|
if isinstance(backchannel_logout_uri, str):
|
||||||
|
backchannel_logout_uri = backchannel_logout_uri.strip() or None
|
||||||
|
|
||||||
if not client_id or not secret_hash_hex:
|
if not client_id or not secret_hash_hex:
|
||||||
raise ValueError("client_id and secret_hash are required")
|
raise ValueError("client_id and secret_hash are required")
|
||||||
@@ -1023,10 +1028,14 @@ async def admin_create_oidc_client(
|
|||||||
if not isinstance(uri, str) or not uri.startswith("http"):
|
if not isinstance(uri, str) or not uri.startswith("http"):
|
||||||
raise ValueError(f"Invalid redirect URI: {uri}")
|
raise ValueError(f"Invalid redirect URI: {uri}")
|
||||||
|
|
||||||
|
if backchannel_logout_uri and not backchannel_logout_uri.startswith("http"):
|
||||||
|
raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")
|
||||||
|
|
||||||
client = OIDClient(
|
client = OIDClient(
|
||||||
client_secret_hash=secret_hash,
|
client_secret_hash=secret_hash,
|
||||||
name=name,
|
name=name,
|
||||||
redirect_uris=redirect_uris,
|
redirect_uris=redirect_uris,
|
||||||
|
backchannel_logout_uri=backchannel_logout_uri,
|
||||||
)
|
)
|
||||||
client.uuid = client_uuid
|
client.uuid = client_uuid
|
||||||
|
|
||||||
@@ -1062,6 +1071,13 @@ async def admin_update_oidc_client(
|
|||||||
secret_hash_hex = (
|
secret_hash_hex = (
|
||||||
payload.get("secret_hash", "").strip() if "secret_hash" in payload else None
|
payload.get("secret_hash", "").strip() if "secret_hash" in payload else None
|
||||||
)
|
)
|
||||||
|
backchannel_logout_uri = (
|
||||||
|
payload.get("backchannel_logout_uri")
|
||||||
|
if "backchannel_logout_uri" in payload
|
||||||
|
else _UNSET
|
||||||
|
)
|
||||||
|
if isinstance(backchannel_logout_uri, str):
|
||||||
|
backchannel_logout_uri = backchannel_logout_uri.strip() or None
|
||||||
|
|
||||||
if name is not None and not name:
|
if name is not None and not name:
|
||||||
raise ValueError("Client name cannot be empty")
|
raise ValueError("Client name cannot be empty")
|
||||||
@@ -1074,6 +1090,9 @@ async def admin_update_oidc_client(
|
|||||||
if not isinstance(uri, str) or not uri.startswith("http"):
|
if not isinstance(uri, str) or not uri.startswith("http"):
|
||||||
raise ValueError(f"Invalid redirect URI: {uri}")
|
raise ValueError(f"Invalid redirect URI: {uri}")
|
||||||
|
|
||||||
|
if backchannel_logout_uri is not _UNSET and backchannel_logout_uri and not backchannel_logout_uri.startswith("http"):
|
||||||
|
raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")
|
||||||
|
|
||||||
secret_hash = None
|
secret_hash = None
|
||||||
if secret_hash_hex:
|
if secret_hash_hex:
|
||||||
try:
|
try:
|
||||||
@@ -1089,6 +1108,7 @@ async def admin_update_oidc_client(
|
|||||||
name=name,
|
name=name,
|
||||||
redirect_uris=redirect_uris,
|
redirect_uris=redirect_uris,
|
||||||
secret_hash=secret_hash,
|
secret_hash=secret_hash,
|
||||||
|
backchannel_logout_uri=backchannel_logout_uri,
|
||||||
ctx=ctx,
|
ctx=ctx,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ async def openid_configuration(request: Request):
|
|||||||
"jwks_uri": f"{issuer}/auth/oidc/keys",
|
"jwks_uri": f"{issuer}/auth/oidc/keys",
|
||||||
"backchannel_logout_supported": True,
|
"backchannel_logout_supported": True,
|
||||||
"backchannel_logout_session_supported": True,
|
"backchannel_logout_session_supported": True,
|
||||||
"backchannel_logout_uri": f"{issuer}/auth/oidc/backchannel-logout",
|
|
||||||
"response_types_supported": ["code"],
|
"response_types_supported": ["code"],
|
||||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||||
"subject_types_supported": ["public"],
|
"subject_types_supported": ["public"],
|
||||||
|
|||||||
+12
-3
@@ -120,7 +120,10 @@ async def token(
|
|||||||
# RFC 6749: Token endpoint MUST NOT accept query parameters
|
# RFC 6749: Token endpoint MUST NOT accept query parameters
|
||||||
if request.url.query:
|
if request.url.query:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": "invalid_request", "error_description": "Query parameters not allowed"},
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Query parameters not allowed",
|
||||||
|
},
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,7 +131,10 @@ async def token(
|
|||||||
content_type = request.headers.get("content-type", "")
|
content_type = request.headers.get("content-type", "")
|
||||||
if "application/x-www-form-urlencoded" not in content_type:
|
if "application/x-www-form-urlencoded" not in content_type:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": "invalid_request", "error_description": "Content-Type must be application/x-www-form-urlencoded"},
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Content-Type must be application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -478,7 +484,10 @@ async def backchannel_logout(
|
|||||||
content_type = request.headers.get("content-type", "")
|
content_type = request.headers.get("content-type", "")
|
||||||
if "application/x-www-form-urlencoded" not in content_type:
|
if "application/x-www-form-urlencoded" not in content_type:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": "invalid_request", "error_description": "Content-Type must be application/x-www-form-urlencoded"},
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Content-Type must be application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
OIDC Back-Channel Logout notifications.
|
||||||
|
|
||||||
|
When sessions are deleted (logout, admin, expiry), this module notifies
|
||||||
|
any OIDC clients that have a backchannel_logout_uri configured.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import base64url
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from paskia import db
|
||||||
|
from paskia.util import oidjwt
|
||||||
|
from paskia.util.crypto import hash_secret
|
||||||
|
from paskia.util.hostutil import _load_config
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Timeout for back-channel logout requests
|
||||||
|
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _issuer() -> str:
|
||||||
|
"""Derive issuer URL from config (same base as discovery document)."""
|
||||||
|
cfg = _load_config()
|
||||||
|
return cfg.get("site_url", "https://localhost")
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_oidc_sessions(
|
||||||
|
session_keys: list[bytes],
|
||||||
|
) -> list[tuple[str, str, UUID, UUID | None]]:
|
||||||
|
"""Collect (backchannel_logout_uri, sid, client_uuid, user_uuid) for OIDC sessions.
|
||||||
|
|
||||||
|
Must be called before the sessions are deleted from the database.
|
||||||
|
Returns only sessions whose client has a backchannel_logout_uri configured.
|
||||||
|
"""
|
||||||
|
notifications = []
|
||||||
|
data = db.data()
|
||||||
|
for key in session_keys:
|
||||||
|
session = data.sessions.get(key)
|
||||||
|
if not session or session.client_uuid is None:
|
||||||
|
continue
|
||||||
|
client = data.oid_clients.get(session.client_uuid)
|
||||||
|
if not client or not client.backchannel_logout_uri:
|
||||||
|
continue
|
||||||
|
sid = base64url.enc(hash_secret("oidc", session.key))
|
||||||
|
notifications.append(
|
||||||
|
(client.backchannel_logout_uri, sid, session.client_uuid, session.user_uuid)
|
||||||
|
)
|
||||||
|
return notifications
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_logout_token(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
uri: str,
|
||||||
|
token: str,
|
||||||
|
) -> None:
|
||||||
|
"""POST a logout_token to a single client endpoint."""
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
uri,
|
||||||
|
data={"logout_token": token},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
_logger.debug("Back-channel logout OK: %s", uri)
|
||||||
|
else:
|
||||||
|
_logger.warning(
|
||||||
|
"Back-channel logout %s returned %d: %s",
|
||||||
|
uri,
|
||||||
|
resp.status_code,
|
||||||
|
resp.text[:200],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_logger.warning("Back-channel logout failed: %s", uri, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def notify(
|
||||||
|
notifications: list[tuple[str, str, UUID, UUID | None]],
|
||||||
|
) -> None:
|
||||||
|
"""Send back-channel logout tokens to all collected endpoints.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notifications: list of (backchannel_logout_uri, sid, client_uuid, user_uuid)
|
||||||
|
as returned by _collect_oidc_sessions.
|
||||||
|
"""
|
||||||
|
if not notifications:
|
||||||
|
return
|
||||||
|
|
||||||
|
issuer = _issuer()
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
tasks = []
|
||||||
|
for uri, sid, client_uuid, user_uuid in notifications:
|
||||||
|
token = oidjwt.create_logout_token(
|
||||||
|
issuer=issuer,
|
||||||
|
audience=str(client_uuid),
|
||||||
|
sid=sid,
|
||||||
|
sub=user_uuid,
|
||||||
|
)
|
||||||
|
tasks.append(_send_logout_token(client, uri, token))
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_notifications(session_keys: list[bytes]) -> None:
|
||||||
|
"""Collect OIDC info from sessions (before deletion) and schedule async notifications.
|
||||||
|
|
||||||
|
Must be called BEFORE the sessions are deleted. The actual HTTP requests
|
||||||
|
are fire-and-forget via the running event loop.
|
||||||
|
"""
|
||||||
|
notifications = _collect_oidc_sessions(session_keys)
|
||||||
|
if not notifications:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.create_task(notify(notifications))
|
||||||
|
except RuntimeError:
|
||||||
|
_logger.debug("No event loop for back-channel logout notifications")
|
||||||
+39
-7
@@ -197,12 +197,44 @@ def decode_access_token(
|
|||||||
decode_kwargs["audience"] = audience
|
decode_kwargs["audience"] = audience
|
||||||
else:
|
else:
|
||||||
options["verify_aud"] = False
|
options["verify_aud"] = False
|
||||||
|
|
||||||
return jwt.decode(
|
return jwt.decode(token, _public_key, options=options, **decode_kwargs)
|
||||||
token,
|
|
||||||
_public_key,
|
|
||||||
options=options,
|
|
||||||
**decode_kwargs
|
|
||||||
)
|
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_logout_token(
|
||||||
|
issuer: str,
|
||||||
|
audience: str,
|
||||||
|
sid: str | None = None,
|
||||||
|
sub: UUID | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Create a signed logout token for back-channel logout notification.
|
||||||
|
|
||||||
|
Per OIDC Back-Channel Logout 1.0, the logout token must contain
|
||||||
|
either sid (session) or sub (user), or both.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issuer: Token issuer (site URL)
|
||||||
|
audience: Client ID (aud claim)
|
||||||
|
sid: Session ID (base64url-encoded)
|
||||||
|
sub: User UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Signed JWT string
|
||||||
|
"""
|
||||||
|
_ensure_key()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
payload = {
|
||||||
|
"iss": issuer,
|
||||||
|
"aud": audience,
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int((now + timedelta(seconds=120)).timestamp()),
|
||||||
|
"events": {"http://schemas.openid.net/event/backchannel-logout": {}},
|
||||||
|
"jti": hashlib.sha256(f"{now.timestamp()}{audience}{sid}{sub}".encode()).hexdigest()[:16],
|
||||||
|
}
|
||||||
|
if sid:
|
||||||
|
payload["sid"] = sid
|
||||||
|
if sub:
|
||||||
|
payload["sub"] = str(sub)
|
||||||
|
return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid})
|
||||||
|
|||||||
Reference in New Issue
Block a user