OAuth2 OpenID Connect provider support etc. #3
+50
-9
@@ -237,6 +237,7 @@ async def _handle_authorization_code(
|
||||
sid,
|
||||
auth_code.oidc.nonce if auth_code.oidc else None,
|
||||
auth_code.oidc.scope if auth_code.oidc else None,
|
||||
credential_uuid=session.credential_uuid,
|
||||
)
|
||||
|
||||
|
||||
@@ -313,6 +314,7 @@ async def _handle_refresh_token(
|
||||
sid_str,
|
||||
nonce=None,
|
||||
scope="openid",
|
||||
credential_uuid=session.credential_uuid,
|
||||
)
|
||||
|
||||
|
||||
@@ -324,6 +326,7 @@ def _build_token_response(
|
||||
sid: str,
|
||||
nonce: str | None,
|
||||
scope: str,
|
||||
credential_uuid: UUID | None = None,
|
||||
):
|
||||
"""Build the token response with access_token, id_token, and refresh_token."""
|
||||
issuer = _get_issuer(request)
|
||||
@@ -340,6 +343,16 @@ def _build_token_response(
|
||||
if p:
|
||||
permissions.append(p.scope)
|
||||
|
||||
# Get credential's last_used as auth_time
|
||||
auth_time = None
|
||||
if credential_uuid:
|
||||
try:
|
||||
credential = db.data().credentials[credential_uuid]
|
||||
if credential.last_used:
|
||||
auth_time = credential.last_used
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Create ID token
|
||||
id_token = oidjwt.create_id_token(
|
||||
issuer=issuer,
|
||||
@@ -351,6 +364,7 @@ def _build_token_response(
|
||||
preferred_username=user.preferred_username,
|
||||
email=user.email,
|
||||
permissions=permissions if permissions else None,
|
||||
auth_time=auth_time,
|
||||
)
|
||||
|
||||
# Create access token
|
||||
@@ -393,6 +407,19 @@ async def userinfo(
|
||||
if not payload:
|
||||
raise HTTPException(401, "Invalid or expired token")
|
||||
|
||||
# Verify audience is a valid client
|
||||
aud = payload.get("aud")
|
||||
if not aud:
|
||||
raise HTTPException(401, "Invalid token (missing aud claim)")
|
||||
|
||||
try:
|
||||
client_uuid = UUID(aud)
|
||||
except ValueError:
|
||||
raise HTTPException(401, "Invalid token (invalid aud format)")
|
||||
|
||||
if not db.data().oid_clients.get(client_uuid):
|
||||
raise HTTPException(401, "Invalid token (unknown client)")
|
||||
|
||||
# Get user
|
||||
try:
|
||||
user_uuid = UUID(payload["sub"])
|
||||
@@ -469,6 +496,29 @@ async def backchannel_logout(
|
||||
sid = payload.get("sid")
|
||||
sub = payload.get("sub")
|
||||
|
||||
# Verify audience is a valid client (if present)
|
||||
aud = payload.get("aud")
|
||||
client_uuid = None
|
||||
if aud:
|
||||
try:
|
||||
client_uuid = UUID(aud)
|
||||
if not db.data().oid_clients.get(client_uuid):
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Unknown client in logout_token",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid client format in logout_token",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not sid and not sub:
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -478,15 +528,6 @@ async def backchannel_logout(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get client from audience
|
||||
aud = payload.get("aud")
|
||||
client_uuid = None
|
||||
if aud:
|
||||
try:
|
||||
client_uuid = UUID(aud)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Delete session(s)
|
||||
deleted = 0
|
||||
if sid:
|
||||
|
||||
@@ -154,6 +154,27 @@ async def websocket_authenticate(
|
||||
await ws.send_json({"status": 400, "detail": "nonce is required for OIDC"})
|
||||
return
|
||||
|
||||
# Validate state parameter if provided (defensive against injection)
|
||||
if state:
|
||||
# State should be short-lived and contain only safe characters
|
||||
# Per OAuth 2.0 spec: unreserved characters - alphanumerics and -._~
|
||||
if len(state) > 500:
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": 400,
|
||||
"detail": "state parameter is too long (max 500 chars)",
|
||||
}
|
||||
)
|
||||
return
|
||||
if not all(c.isalnum() or c in "-._~" for c in state):
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": 400,
|
||||
"detail": "state parameter contains invalid characters",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# If there's an existing session, restrict to that user's credentials (reauth)
|
||||
session_user_uuid = None
|
||||
if auth:
|
||||
|
||||
@@ -91,6 +91,7 @@ def create_id_token(
|
||||
preferred_username: str | None = None,
|
||||
email: str | None = None,
|
||||
permissions: list[str] | None = None,
|
||||
auth_time: datetime | None = None,
|
||||
expires_in: int = 3600,
|
||||
) -> str:
|
||||
"""Create a signed ID token (JWT).
|
||||
@@ -105,6 +106,7 @@ def create_id_token(
|
||||
preferred_username: User's preferred username
|
||||
email: User's email address
|
||||
permissions: List of permission scopes
|
||||
auth_time: When the user authenticated (last credential use time)
|
||||
expires_in: Token lifetime in seconds
|
||||
|
||||
Returns:
|
||||
@@ -131,6 +133,8 @@ def create_id_token(
|
||||
payload["email"] = email
|
||||
if permissions:
|
||||
payload["permissions"] = permissions
|
||||
if auth_time:
|
||||
payload["auth_time"] = int(auth_time.timestamp())
|
||||
|
||||
return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid})
|
||||
|
||||
@@ -167,12 +171,15 @@ def create_access_token(
|
||||
return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid})
|
||||
|
||||
|
||||
def decode_access_token(token: str, issuer: str) -> dict | None:
|
||||
def decode_access_token(
|
||||
token: str, issuer: str, audience: str | None = None
|
||||
) -> dict | None:
|
||||
"""Decode and verify an access token.
|
||||
|
||||
Args:
|
||||
token: JWT string
|
||||
issuer: Expected issuer
|
||||
audience: Optional expected audience (client_id). If provided, aud claim must match.
|
||||
|
||||
Returns:
|
||||
Decoded payload or None if invalid
|
||||
@@ -184,7 +191,7 @@ def decode_access_token(token: str, issuer: str) -> dict | None:
|
||||
_public_key,
|
||||
algorithms=["EdDSA"],
|
||||
issuer=issuer,
|
||||
options={"verify_aud": False}, # We'll verify audience separately if needed
|
||||
audience=audience, # PyJWT handles None by skipping verification
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user