mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 13:40:37 +00:00
* fix(auth): treat a signed token with wrong claims as malformed
decode_token caught jwt.PyJWTError but not pydantic.ValidationError, so a
correctly signed token without sub/exp raised out of the function. The
OIDC state cookie shares jwt_secret and has exactly that shape, and the
OAuth start endpoint is public, so an unauthenticated caller could turn
it into a 500 (or a broken WebSocket handshake) instead of a 401.
Catch ValidationError and return TokenError.MALFORMED.
The regression test signs an OIDC-state-shaped payload and asserts
MALFORMED; it raises on main.
* fix(auth): treat a signed OIDC state cookie with bad claims as unusable
_verify_state_signed had the same latent shape as decode_token: it caught
only jwt.PyJWTError, so a state cookie signed with jwt_secret but missing
required claims raised pydantic.ValidationError straight into the public
/api/v1/auth/callback/{provider} endpoint instead of returning None and
letting the handler answer 403.
Also pin the decode_token precedence a reviewer raised: an expired token
that is missing sub reports EXPIRED because jwt.decode raises before the
payload model runs.
* docs: reduce inherited backend guidance size
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
125 lines
4.6 KiB
Python
125 lines
4.6 KiB
Python
"""OIDC state management via signed HttpOnly cookies.
|
|
|
|
Stores OIDC state, nonce, and PKCE verifier in a short-lived signed cookie
|
|
instead of server-side storage. This keeps the implementation stateless and
|
|
compatible with multi-worker deployments without Redis.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
import time
|
|
|
|
import jwt
|
|
from fastapi import Request, Response
|
|
from pydantic import BaseModel, Field, ValidationError
|
|
|
|
from app.gateway.auth.config import get_auth_config
|
|
from app.gateway.csrf_middleware import is_secure_request
|
|
|
|
OIDC_STATE_COOKIE_PREFIX = "df_oidc_state_"
|
|
OIDC_STATE_MAX_AGE = 300 # 5 minutes
|
|
OIDC_STATE_BYTES = 32
|
|
OIDC_NONCE_BYTES = 16
|
|
OIDC_CODE_VERIFIER_BYTES = 32
|
|
|
|
|
|
class OIDCStatePayload(BaseModel):
|
|
"""Payload stored inside the signed OIDC state cookie."""
|
|
|
|
provider: str = Field(description="OIDC provider ID (must match the state cookie)") # noqa: E501
|
|
state: str = Field(description="Cryptographically random state value — compared in constant time with the query param") # noqa: E501
|
|
nonce: str | None = Field(default=None, description="OIDC nonce, verified against the ID token nonce claim")
|
|
code_verifier: str | None = Field(default=None, description="PKCE code verifier, sent during token exchange")
|
|
next_path: str = Field(default="/workspace", description="Redirect target after successful auth")
|
|
remember_me: bool = Field(default=True, description="Whether the resulting DeerFlow session should be persistent")
|
|
issued_at: float = Field(default_factory=time.time, description="Unix timestamp of cookie creation")
|
|
|
|
|
|
def _sign_state_payload(payload: OIDCStatePayload) -> str:
|
|
"""Sign the state payload with the JWT secret to prevent tampering."""
|
|
secret = get_auth_config().jwt_secret
|
|
return jwt.encode(payload.model_dump(), secret, algorithm="HS256")
|
|
|
|
|
|
def _verify_state_signed(signed: str, max_age: int = OIDC_STATE_MAX_AGE) -> OIDCStatePayload | None:
|
|
"""Verify a signed state payload and return it, or None if invalid/expired."""
|
|
secret = get_auth_config().jwt_secret
|
|
try:
|
|
decoded = jwt.decode(signed, secret, algorithms=["HS256"])
|
|
payload = OIDCStatePayload(**decoded)
|
|
if time.time() - payload.issued_at > max_age:
|
|
return None
|
|
return payload
|
|
except (jwt.PyJWTError, ValidationError):
|
|
# A state cookie signed with our secret but missing required claims is not a server error:
|
|
# it is an unusable state, and the callback must answer 403 rather than 500.
|
|
return None
|
|
|
|
|
|
def generate_oidc_state() -> str:
|
|
"""Generate a cryptographically random state string."""
|
|
return secrets.token_urlsafe(OIDC_STATE_BYTES)
|
|
|
|
|
|
def generate_nonce() -> str:
|
|
"""Generate a cryptographically random nonce for ID token validation."""
|
|
return secrets.token_urlsafe(OIDC_NONCE_BYTES)
|
|
|
|
|
|
def generate_code_verifier() -> str:
|
|
"""Generate a PKCE code verifier (plain random string)."""
|
|
return secrets.token_urlsafe(OIDC_CODE_VERIFIER_BYTES)
|
|
|
|
|
|
def compute_code_challenge(verifier: str) -> str:
|
|
"""Compute the S256 PKCE code challenge from a verifier."""
|
|
import hashlib
|
|
|
|
return _base64url_encode(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
|
|
|
|
def _base64url_encode(data: bytes) -> str:
|
|
"""Base64url-encode without padding, as required by RFC 7636 and OIDC."""
|
|
import base64
|
|
|
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
|
|
|
|
def _cookie_name(provider: str) -> str:
|
|
return f"{OIDC_STATE_COOKIE_PREFIX}{provider}"
|
|
|
|
|
|
def set_state_cookie(response: Response, request: Request, payload: OIDCStatePayload) -> None:
|
|
"""Set the signed OIDC state cookie on the response."""
|
|
signed = _sign_state_payload(payload)
|
|
is_https = is_secure_request(request)
|
|
response.set_cookie(
|
|
key=_cookie_name(payload.provider),
|
|
value=signed,
|
|
httponly=True,
|
|
secure=is_https,
|
|
samesite="lax",
|
|
max_age=OIDC_STATE_MAX_AGE,
|
|
path=f"/api/v1/auth/callback/{payload.provider}",
|
|
)
|
|
|
|
|
|
def get_state_cookie(request: Request, provider: str) -> OIDCStatePayload | None:
|
|
"""Read and verify the signed OIDC state cookie for the given provider."""
|
|
signed = request.cookies.get(_cookie_name(provider))
|
|
if not signed:
|
|
return None
|
|
return _verify_state_signed(signed)
|
|
|
|
|
|
def delete_state_cookie(response: Response, request: Request, provider: str) -> None:
|
|
"""Delete the OIDC state cookie."""
|
|
is_https = is_secure_request(request)
|
|
response.delete_cookie(
|
|
key=_cookie_name(provider),
|
|
secure=is_https,
|
|
samesite="lax",
|
|
path=f"/api/v1/auth/callback/{provider}",
|
|
)
|