mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(auth): treat a signed token with wrong claims as malformed (#7266)
* 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>
This commit is contained in:
parent
b57488734d
commit
c0ba948825
@ -26,36 +26,20 @@ The LangGraph backend provides sandbox execution, persistent memory, subagent de
|
||||
- Gateway MCP/skill updates write `extensions_config.json` at runtime. Production Compose mounts it read-write (`config.yaml` stays `:ro`); Helm seeds a writable home-volume copy. Hold both `extensions_config_write_lock` and the sidecar advisory `extensions_config_file_lock` during read-modify-write to prevent lost updates across workers. `atomic_write_extensions_config` uses temp-file replacement; only mount-point `EBUSY` permits a non-atomic in-place overwrite (crashes can truncate). Warn about this fallback once per target; propagate other errors. `get_extensions_config()` revalidates reads by file signature; see [config caching](packages/harness/deerflow/config/AGENTS.md). Tests: `tests/test_compose_extensions_config_writable.py`, `tests/test_extensions_config_atomic_write.py`, `tests/test_helm_extensions_config_writable.py`.
|
||||
- MCP cache reset scope and discovery fencing: [MCP guide](packages/harness/deerflow/mcp/AGENTS.md).
|
||||
|
||||
**Project Structure** (backend/ subtree; the root guide maps the full repo):
|
||||
```
|
||||
backend/
|
||||
├── Makefile # Backend-only commands (dev, gateway, lint)
|
||||
├── langgraph.json # LangGraph Studio graph configuration
|
||||
├── packages/
|
||||
│ ├── extension-api/ # public, host-independent extension contracts (import: deerflow_extension_api.*)
|
||||
│ └── harness/ # deerflow-harness package (import: deerflow.*)
|
||||
│ └── deerflow/
|
||||
│ ├── agents/ # LangGraph agent system (lead_agent/, middlewares/ — see Middleware Chain, memory/, thread_state.py)
|
||||
│ ├── sandbox/ # Sandbox execution system
|
||||
│ ├── subagents/ # Subagent delegation (builtins/, executor.py, registry.py)
|
||||
│ ├── tools/builtins/ # Built-in tools (present_files, ask_clarification, view_image, review_skill_package)
|
||||
│ ├── mcp/ # MCP integration (tools, cache, client)
|
||||
│ ├── integrations/ # Managed first-party integration installers (e.g. Lark CLI skill pack)
|
||||
│ ├── extensions/ # Python plugin loader, registry, placement, and isolation
|
||||
│ ├── models/ # Model factory with thinking/vision support
|
||||
│ ├── skills/ # Skills discovery, loading, parsing
|
||||
│ ├── config/ # Configuration system
|
||||
│ ├── community/ # Community tools (search/fetch/scrape, image search, AIO sandbox)
|
||||
│ ├── reflection/ # Dynamic module loading (resolve_variable, resolve_class)
|
||||
│ ├── utils/ # Utilities (network, readability)
|
||||
│ └── client.py # Embedded Python client (DeerFlowClient)
|
||||
├── app/ # Application layer (import: app.*)
|
||||
│ ├── gateway/ # FastAPI Gateway API (app.py, routers/)
|
||||
│ └── channels/ # IM platform integrations
|
||||
├── scripts/benchmark/ # Standalone reproducible backend benchmarks
|
||||
├── tests/ # Test suite
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
**Project Structure** (relative to `backend/`; see the root guide for the full repo):
|
||||
|
||||
- `Makefile`: backend commands; `langgraph.json`: LangGraph Studio configuration.
|
||||
- `packages/extension-api/`: public extension contracts (`deerflow_extension_api.*`).
|
||||
- `packages/harness/deerflow/`: publishable framework (`deerflow.*`):
|
||||
`agents/` (lead agent, middleware, memory, thread state), `sandbox/`, `subagents/`
|
||||
(builtins, executor, registry), `tools/builtins/` (file presentation, clarification,
|
||||
images, skill review), `mcp/`, `integrations/` (Lark CLI skill pack), `extensions/`,
|
||||
`models/`, `skills/`, `config/`, `community/` (search, fetch, scrape, images,
|
||||
AIO sandbox), `reflection/`, `utils/`, and the embedded `client.py`.
|
||||
- `app/`: application layer (`app.*`), including `gateway/` (FastAPI app and routers)
|
||||
and `channels/` (IM integrations).
|
||||
- `scripts/benchmark/`: standalone backend benchmarks; `tests/`: test suite;
|
||||
`docs/`: backend documentation.
|
||||
|
||||
## Important Development Guidelines
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from app.gateway.auth.config import get_auth_config
|
||||
from app.gateway.auth.errors import TokenError
|
||||
@ -51,5 +51,8 @@ def decode_token(token: str) -> TokenPayload | TokenError:
|
||||
return TokenError.EXPIRED
|
||||
except jwt.InvalidSignatureError:
|
||||
return TokenError.INVALID_SIGNATURE
|
||||
except jwt.PyJWTError:
|
||||
except (jwt.PyJWTError, ValidationError):
|
||||
# A correctly signed token with the wrong claims (e.g. the OIDC state
|
||||
# cookie, which shares jwt_secret but has no sub/exp) is malformed, not
|
||||
# a server error.
|
||||
return TokenError.MALFORMED
|
||||
|
||||
@ -12,7 +12,7 @@ import time
|
||||
|
||||
import jwt
|
||||
from fastapi import Request, Response
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from app.gateway.auth.config import get_auth_config
|
||||
from app.gateway.csrf_middleware import is_secure_request
|
||||
@ -51,7 +51,9 @@ def _verify_state_signed(signed: str, max_age: int = OIDC_STATE_MAX_AGE) -> OIDC
|
||||
if time.time() - payload.issued_at > max_age:
|
||||
return None
|
||||
return payload
|
||||
except jwt.PyJWTError:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import jwt as pyjwt
|
||||
from app.gateway.auth.config import AuthConfig, set_auth_config
|
||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError
|
||||
from app.gateway.auth.jwt import create_access_token, decode_token
|
||||
from app.gateway.auth.oidc_state import _verify_state_signed
|
||||
|
||||
|
||||
def test_auth_error_code_values():
|
||||
@ -73,3 +74,38 @@ def test_decode_token_returns_payload_on_valid():
|
||||
result = decode_token(token)
|
||||
assert not isinstance(result, TokenError)
|
||||
assert result.sub == "user-123"
|
||||
|
||||
|
||||
def test_decode_token_returns_token_error_on_signed_token_with_wrong_claims():
|
||||
_setup_config()
|
||||
# The OIDC state cookie is signed with the same secret but has no sub/exp.
|
||||
state_payload = {"provider": "google", "state": "abc", "issued_at": 1}
|
||||
token = pyjwt.encode(state_payload, _TEST_SECRET, algorithm="HS256")
|
||||
|
||||
assert decode_token(token) == TokenError.MALFORMED
|
||||
|
||||
|
||||
def test_decode_token_prefers_expired_over_malformed():
|
||||
_setup_config()
|
||||
# jwt.decode raises ExpiredSignatureError before TokenPayload(**payload) runs, so a token that is
|
||||
# both expired and missing `sub` must keep reporting EXPIRED.
|
||||
expired_without_sub = {"provider": "x", "exp": datetime.now(UTC) - timedelta(hours=1)}
|
||||
token = pyjwt.encode(expired_without_sub, _TEST_SECRET, algorithm="HS256")
|
||||
assert decode_token(token) == TokenError.EXPIRED
|
||||
|
||||
|
||||
def test_verify_state_signed_rejects_signed_payload_with_bad_claims():
|
||||
_setup_config()
|
||||
# A cookie signed with our secret but missing required claims used to raise ValidationError out of
|
||||
# the public callback endpoint; it has to be treated like any other unusable state.
|
||||
signed = pyjwt.encode({"not_provider": "x"}, _TEST_SECRET, algorithm="HS256")
|
||||
assert _verify_state_signed(signed) is None
|
||||
|
||||
|
||||
def test_verify_state_signed_accepts_a_valid_state():
|
||||
_setup_config()
|
||||
now = datetime.now(UTC).timestamp()
|
||||
signed = pyjwt.encode({"provider": "google", "state": "abc", "issued_at": now}, _TEST_SECRET, algorithm="HS256")
|
||||
payload = _verify_state_signed(signed)
|
||||
assert payload is not None
|
||||
assert payload.provider == "google"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user