Files
deer-flow/backend/app/gateway/auth/oidc_state.py
T
Ryker_FengandGitHub a028dfd5fb feat(auth): add keep me signed in login option (#4255)
* feat(auth): add keep me signed in login option

* fix(auth): address remember-login review feedback
2026-07-18 22:17:15 +08:00

123 lines
4.4 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
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:
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}",
)