graph-agents-cli 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graph_agents_cli/__init__.py +26 -0
- graph_agents_cli/_api_policy.py +2145 -0
- graph_agents_cli/_approvals.py +400 -0
- graph_agents_cli/_build.py +186 -0
- graph_agents_cli/_build_info.json +7 -0
- graph_agents_cli/_chat_client.py +462 -0
- graph_agents_cli/_click.py +157 -0
- graph_agents_cli/_defaults.py +139 -0
- graph_agents_cli/_experiments.py +64 -0
- graph_agents_cli/_http.py +192 -0
- graph_agents_cli/_output.py +83 -0
- graph_agents_cli/_project.py +462 -0
- graph_agents_cli/_remote.py +220 -0
- graph_agents_cli/_response_schema.py +264 -0
- graph_agents_cli/_runner.py +319 -0
- graph_agents_cli/_skills_check.py +274 -0
- graph_agents_cli/_tools.py +189 -0
- graph_agents_cli/_trust.py +66 -0
- graph_agents_cli/api/__init__.py +15 -0
- graph_agents_cli/api/_changes.py +506 -0
- graph_agents_cli/api/_files.py +658 -0
- graph_agents_cli/api/cmd_api.py +2480 -0
- graph_agents_cli/deploy/__init__.py +15 -0
- graph_agents_cli/deploy/_config.py +171 -0
- graph_agents_cli/deploy/_image.py +128 -0
- graph_agents_cli/deploy/_kube.py +286 -0
- graph_agents_cli/deploy/_modes.py +234 -0
- graph_agents_cli/deploy/_preflight.py +370 -0
- graph_agents_cli/deploy/_values.py +168 -0
- graph_agents_cli/deploy/cmd_deploy.py +1866 -0
- graph_agents_cli/deploy/gitops.py +562 -0
- graph_agents_cli/deploy/local_load.py +273 -0
- graph_agents_cli/dev/__init__.py +13 -0
- graph_agents_cli/dev/cmd_build.py +131 -0
- graph_agents_cli/dev/cmd_install.py +78 -0
- graph_agents_cli/dev/cmd_lint.py +119 -0
- graph_agents_cli/dev/cmd_playground.py +297 -0
- graph_agents_cli/dev/policy_check.py +1287 -0
- graph_agents_cli/eval/__init__.py +22 -0
- graph_agents_cli/eval/_client.py +670 -0
- graph_agents_cli/eval/_common.py +177 -0
- graph_agents_cli/eval/_judge.py +168 -0
- graph_agents_cli/eval/_judge_runner.py +238 -0
- graph_agents_cli/eval/_paths.py +212 -0
- graph_agents_cli/eval/checks.py +581 -0
- graph_agents_cli/eval/cmd_analyze.py +278 -0
- graph_agents_cli/eval/cmd_compare.py +284 -0
- graph_agents_cli/eval/cmd_eval_group.py +80 -0
- graph_agents_cli/eval/cmd_generate.py +558 -0
- graph_agents_cli/eval/cmd_grade.py +466 -0
- graph_agents_cli/eval/cmd_metric.py +156 -0
- graph_agents_cli/eval/cmd_run.py +370 -0
- graph_agents_cli/eval/cmd_submit.py +400 -0
- graph_agents_cli/eval/config.py +435 -0
- graph_agents_cli/eval/dataset.py +350 -0
- graph_agents_cli/eval/gate.py +420 -0
- graph_agents_cli/eval/transcript.py +192 -0
- graph_agents_cli/extension/__init__.py +13 -0
- graph_agents_cli/extension/_compat.py +86 -0
- graph_agents_cli/extension/_loader.py +293 -0
- graph_agents_cli/extension/_manifest.py +135 -0
- graph_agents_cli/extension/_overrides.py +195 -0
- graph_agents_cli/extension/_paths.py +91 -0
- graph_agents_cli/extension/_refs.py +193 -0
- graph_agents_cli/extension/_resolver.py +453 -0
- graph_agents_cli/extension/_schema.py +106 -0
- graph_agents_cli/extension/_spec.py +253 -0
- graph_agents_cli/extension/_sync.py +102 -0
- graph_agents_cli/extension/_trust.py +58 -0
- graph_agents_cli/extension/cmd_extension_add.py +259 -0
- graph_agents_cli/extension/cmd_extension_group.py +57 -0
- graph_agents_cli/extension/cmd_extension_list.py +56 -0
- graph_agents_cli/extension/cmd_extension_remove.py +61 -0
- graph_agents_cli/extension/cmd_extension_update.py +195 -0
- graph_agents_cli/info/__init__.py +13 -0
- graph_agents_cli/info/cmd_info.py +222 -0
- graph_agents_cli/infra/__init__.py +15 -0
- graph_agents_cli/infra/checks.py +1169 -0
- graph_agents_cli/infra/cmd_infra.py +103 -0
- graph_agents_cli/main.py +591 -0
- graph_agents_cli/peer/__init__.py +15 -0
- graph_agents_cli/peer/_generate.py +254 -0
- graph_agents_cli/peer/cmd_peer.py +1151 -0
- graph_agents_cli/run/__init__.py +13 -0
- graph_agents_cli/run/_local_server.py +1157 -0
- graph_agents_cli/run/_signals.py +141 -0
- graph_agents_cli/run/cmd_approvals.py +530 -0
- graph_agents_cli/run/cmd_run.py +1421 -0
- graph_agents_cli/scaffold/__init__.py +19 -0
- graph_agents_cli/scaffold/agents/README.md +24 -0
- graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
- graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
- graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
- graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
- graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
- graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
- graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
- graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
- graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
- graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
- graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
- graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
- graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
- graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
- graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
- graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
- graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
- graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
- graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
- graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
- graph_agents_cli/scaffold/commands/__init__.py +13 -0
- graph_agents_cli/scaffold/commands/create.py +1424 -0
- graph_agents_cli/scaffold/commands/enhance.py +1652 -0
- graph_agents_cli/scaffold/commands/upgrade.py +570 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
- graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
- graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
- graph_agents_cli/scaffold/utils/__init__.py +13 -0
- graph_agents_cli/scaffold/utils/backup.py +212 -0
- graph_agents_cli/scaffold/utils/build_record.py +257 -0
- graph_agents_cli/scaffold/utils/cli_options.py +184 -0
- graph_agents_cli/scaffold/utils/fs.py +83 -0
- graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
- graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
- graph_agents_cli/scaffold/utils/keyedit.py +768 -0
- graph_agents_cli/scaffold/utils/keymerge.py +537 -0
- graph_agents_cli/scaffold/utils/language.py +138 -0
- graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
- graph_agents_cli/scaffold/utils/logging.py +77 -0
- graph_agents_cli/scaffold/utils/manifest.py +292 -0
- graph_agents_cli/scaffold/utils/merge.py +970 -0
- graph_agents_cli/scaffold/utils/merge3.py +216 -0
- graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
- graph_agents_cli/scaffold/utils/remote_template.py +376 -0
- graph_agents_cli/scaffold/utils/template.py +1352 -0
- graph_agents_cli/scaffold/utils/upgrade.py +894 -0
- graph_agents_cli/scaffold/utils/version.py +438 -0
- graph_agents_cli/secrets/__init__.py +15 -0
- graph_agents_cli/secrets/_apply.py +954 -0
- graph_agents_cli/secrets/_required.py +188 -0
- graph_agents_cli/secrets/cmd_secrets.py +211 -0
- graph_agents_cli/setup/__init__.py +13 -0
- graph_agents_cli/setup/_antigravity.py +221 -0
- graph_agents_cli/setup/cmd_auth.py +1030 -0
- graph_agents_cli/setup/cmd_dev_token.py +513 -0
- graph_agents_cli/setup/cmd_setup.py +428 -0
- graph_agents_cli/setup/cmd_update.py +140 -0
- graph_agents_cli/skills/__init__.py +13 -0
- graph_agents_cli/skills/_bundle.py +65 -0
- graph_agents_cli/skills/data/README.md +19 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
- graph_agents_cli/system/__init__.py +15 -0
- graph_agents_cli/system/_apply.py +519 -0
- graph_agents_cli/system/_checks.py +1023 -0
- graph_agents_cli/system/_deploy.py +215 -0
- graph_agents_cli/system/_model.py +363 -0
- graph_agents_cli/system/_system.py +664 -0
- graph_agents_cli/system/_views.py +208 -0
- graph_agents_cli/system/cmd_system.py +423 -0
- graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
- graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
- graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
- graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
- graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
- graph_agents_cli-0.3.1.dist-info/licenses/NOTICE +19 -0
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
# Copyright 2026 graph-agents-cli contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""The `jwt` auth policy: verification, key handling, settings and both runtimes' wiring.
|
|
16
|
+
|
|
17
|
+
Keys are generated locally (RSA, EC P-256 and P-384, Ed25519); the JWKS is
|
|
18
|
+
served by a throwaway HTTP server on 127.0.0.1. No other network access.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import base64
|
|
25
|
+
import hashlib
|
|
26
|
+
import hmac
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from collections.abc import Iterator
|
|
32
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import jwt
|
|
36
|
+
import pytest
|
|
37
|
+
from cryptography.hazmat.primitives import serialization
|
|
38
|
+
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa
|
|
39
|
+
from fastapi import HTTPException
|
|
40
|
+
from starlette.requests import Request
|
|
41
|
+
|
|
42
|
+
from {{cookiecutter.agent_directory}}.app_utils import auth as auth_module
|
|
43
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import (
|
|
44
|
+
ACTIONS,
|
|
45
|
+
DEFAULT_ALLOWED_ACTORS,
|
|
46
|
+
DEFAULT_JWT_ACTOR_CLAIM,
|
|
47
|
+
JwksCache,
|
|
48
|
+
JwksUnavailable,
|
|
49
|
+
JwtPolicy,
|
|
50
|
+
JwtSettings,
|
|
51
|
+
Principal,
|
|
52
|
+
actor_from_claims,
|
|
53
|
+
build_sdk_auth,
|
|
54
|
+
check_startup,
|
|
55
|
+
finalize_principal,
|
|
56
|
+
get_policy,
|
|
57
|
+
require,
|
|
58
|
+
reset_policy_cache,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
ISSUER = "https://issuer.test/"
|
|
62
|
+
AUDIENCE = "agent-api"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- keys, tokens and requests -------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _rsa() -> rsa.RSAPrivateKey:
|
|
69
|
+
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
RSA_A = _rsa()
|
|
73
|
+
RSA_B = _rsa()
|
|
74
|
+
EC_P256 = ec.generate_private_key(ec.SECP256R1())
|
|
75
|
+
EC_P384 = ec.generate_private_key(ec.SECP384R1())
|
|
76
|
+
ED25519 = ed25519.Ed25519PrivateKey.generate()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _pem(private_key: Any) -> str:
|
|
80
|
+
return (
|
|
81
|
+
private_key.public_key()
|
|
82
|
+
.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
83
|
+
.decode()
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _jwk(private_key: Any, kid: str, **extra: Any) -> dict[str, Any]:
|
|
88
|
+
public = private_key.public_key()
|
|
89
|
+
if isinstance(public, rsa.RSAPublicKey):
|
|
90
|
+
data = jwt.algorithms.RSAAlgorithm.to_jwk(public, as_dict=True)
|
|
91
|
+
elif isinstance(public, ec.EllipticCurvePublicKey):
|
|
92
|
+
data = jwt.algorithms.ECAlgorithm.to_jwk(public, as_dict=True)
|
|
93
|
+
else:
|
|
94
|
+
data = jwt.algorithms.OKPAlgorithm.to_jwk(public, as_dict=True)
|
|
95
|
+
return {**data, "kid": kid, "use": "sig", **extra}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _claims(**overrides: Any) -> dict[str, Any]:
|
|
99
|
+
now = int(time.time())
|
|
100
|
+
claims: dict[str, Any] = {
|
|
101
|
+
"sub": "user-1",
|
|
102
|
+
"iss": ISSUER,
|
|
103
|
+
"aud": AUDIENCE,
|
|
104
|
+
"iat": now,
|
|
105
|
+
"nbf": now,
|
|
106
|
+
"exp": now + 300,
|
|
107
|
+
"roles": ["viewer", "support"],
|
|
108
|
+
}
|
|
109
|
+
claims.update(overrides)
|
|
110
|
+
return {k: v for k, v in claims.items() if v is not None}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _token(key: Any, alg: str = "RS256", kid: str | None = "a", **claims: Any) -> str:
|
|
114
|
+
headers = {"kid": kid} if kid is not None else None
|
|
115
|
+
return jwt.encode(_claims(**claims), key, algorithm=alg, headers=headers)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _b64(data: bytes) -> str:
|
|
119
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _forge(header: dict[str, Any], claims: dict[str, Any], secret: bytes | None = None) -> str:
|
|
123
|
+
"""A token signed with HMAC-SHA256 over `secret` (or unsigned when None)."""
|
|
124
|
+
signing_input = f"{_b64(json.dumps(header).encode())}.{_b64(json.dumps(claims).encode())}"
|
|
125
|
+
if secret is None:
|
|
126
|
+
return signing_input + "."
|
|
127
|
+
signature = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest()
|
|
128
|
+
return f"{signing_input}.{_b64(signature)}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _request(token: str | None = None, scheme: str = "Bearer") -> Request:
|
|
132
|
+
headers = [(b"authorization", f"{scheme} {token}".encode())] if token is not None else []
|
|
133
|
+
return Request(
|
|
134
|
+
{"type": "http", "method": "POST", "path": "/chat", "headers": headers, "query_string": b""}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _settings(**env: str) -> JwtSettings:
|
|
139
|
+
base = {
|
|
140
|
+
"APP_ENV": "prod",
|
|
141
|
+
"AUTH_JWT_ISSUER": ISSUER,
|
|
142
|
+
"AUTH_JWT_AUDIENCE": AUDIENCE,
|
|
143
|
+
"AUTH_JWT_PUBLIC_KEY": _pem(RSA_A),
|
|
144
|
+
}
|
|
145
|
+
base.update(env)
|
|
146
|
+
return JwtSettings.from_env({k: v for k, v in base.items() if v is not None})
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def _reject(policy: JwtPolicy, token: str | None, reason: str | None = None) -> HTTPException:
|
|
150
|
+
with pytest.raises(HTTPException) as exc:
|
|
151
|
+
await policy.authenticate(_request(token))
|
|
152
|
+
assert exc.value.status_code == 401, exc.value.detail
|
|
153
|
+
assert exc.value.headers and exc.value.headers["WWW-Authenticate"].startswith("Bearer")
|
|
154
|
+
if reason is not None:
|
|
155
|
+
assert reason in str(exc.value.detail), exc.value.detail
|
|
156
|
+
return exc.value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --- a JWKS served from a local HTTP server ----------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class JwksServer:
|
|
163
|
+
def __init__(self) -> None:
|
|
164
|
+
self.keys: list[dict[str, Any]] = []
|
|
165
|
+
self.status = 200
|
|
166
|
+
self.body: bytes | None = None
|
|
167
|
+
self.hits = 0
|
|
168
|
+
server = self
|
|
169
|
+
|
|
170
|
+
class Handler(BaseHTTPRequestHandler):
|
|
171
|
+
def do_GET(self) -> None:
|
|
172
|
+
server.hits += 1
|
|
173
|
+
body = server.body or json.dumps({"keys": server.keys}).encode()
|
|
174
|
+
self.send_response(server.status)
|
|
175
|
+
self.send_header("Content-Type", "application/json")
|
|
176
|
+
self.send_header("Content-Length", str(len(body)))
|
|
177
|
+
self.end_headers()
|
|
178
|
+
self.wfile.write(body)
|
|
179
|
+
|
|
180
|
+
def log_message(self, *args: Any) -> None:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
class QuietServer(ThreadingHTTPServer):
|
|
184
|
+
def handle_error(self, request: Any, client_address: Any) -> None:
|
|
185
|
+
pass # a client that stops reading an oversized body is expected
|
|
186
|
+
|
|
187
|
+
self.httpd = QuietServer(("127.0.0.1", 0), Handler)
|
|
188
|
+
self.url = f"http://127.0.0.1:{self.httpd.server_address[1]}/.well-known/jwks.json"
|
|
189
|
+
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
|
190
|
+
self.thread.start()
|
|
191
|
+
|
|
192
|
+
def close(self) -> None:
|
|
193
|
+
self.httpd.shutdown()
|
|
194
|
+
self.httpd.server_close()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.fixture
|
|
198
|
+
def jwks() -> Iterator[JwksServer]:
|
|
199
|
+
server = JwksServer()
|
|
200
|
+
server.keys = [_jwk(RSA_A, "a")]
|
|
201
|
+
try:
|
|
202
|
+
yield server
|
|
203
|
+
finally:
|
|
204
|
+
server.close()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _jwks_policy(server: JwksServer, **env: str) -> JwtPolicy:
|
|
208
|
+
return JwtPolicy(
|
|
209
|
+
_settings(AUTH_JWT_PUBLIC_KEY=None, AUTH_JWT_JWKS_URL=server.url, **env) # type: ignore[arg-type]
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class FakeClock:
|
|
214
|
+
def __init__(self) -> None:
|
|
215
|
+
self.now = 1000.0
|
|
216
|
+
|
|
217
|
+
def __call__(self) -> float:
|
|
218
|
+
return self.now
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --- verification ---------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def test_rs256_token_from_a_jwks_gives_the_principal_and_roles(jwks: JwksServer) -> None:
|
|
225
|
+
policy = _jwks_policy(jwks)
|
|
226
|
+
assert policy.startup_problems() == []
|
|
227
|
+
principal = await policy.authenticate(_request(_token(RSA_A)))
|
|
228
|
+
assert principal.id == "user-1"
|
|
229
|
+
assert principal.roles == ["viewer", "support"]
|
|
230
|
+
assert principal.permissions == set(ACTIONS)
|
|
231
|
+
assert principal.attributes == {}
|
|
232
|
+
for action in ACTIONS:
|
|
233
|
+
await policy.authorize(principal, action, None)
|
|
234
|
+
with pytest.raises(HTTPException) as exc:
|
|
235
|
+
await policy.authorize(principal, "thread.export", None)
|
|
236
|
+
assert exc.value.status_code == 403
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def test_ec_and_eddsa_tokens_verify_against_a_static_pem_key() -> None:
|
|
240
|
+
es = JwtPolicy(_settings(AUTH_JWT_PUBLIC_KEY=_pem(EC_P256), AUTH_JWT_ALGORITHMS="ES256"))
|
|
241
|
+
assert (await es.authenticate(_request(_token(EC_P256, "ES256")))).id == "user-1"
|
|
242
|
+
ed = JwtPolicy(_settings(AUTH_JWT_PUBLIC_KEY=_pem(ED25519), AUTH_JWT_ALGORITHMS="EdDSA"))
|
|
243
|
+
assert (await ed.authenticate(_request(_token(ED25519, "EdDSA")))).id == "user-1"
|
|
244
|
+
# The default allow-list is RS256,ES256; a PEM with escaped newlines (as
|
|
245
|
+
# some secret stores deliver it) is accepted too.
|
|
246
|
+
escaped = _pem(RSA_A).replace("\n", "\\n")
|
|
247
|
+
rs = JwtPolicy(_settings(AUTH_JWT_PUBLIC_KEY=escaped))
|
|
248
|
+
assert (await rs.authenticate(_request(_token(RSA_A)))).id == "user-1"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def test_ec_token_from_a_jwks(jwks: JwksServer) -> None:
|
|
252
|
+
jwks.keys = [_jwk(RSA_A, "a"), _jwk(EC_P256, "e1")]
|
|
253
|
+
policy = _jwks_policy(jwks)
|
|
254
|
+
assert (await policy.authenticate(_request(_token(EC_P256, "ES256", kid="e1")))).id == "user-1"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@pytest.mark.parametrize(
|
|
258
|
+
("claims", "reason"),
|
|
259
|
+
[
|
|
260
|
+
({"exp": int(time.time()) - 3600}, "token expired"),
|
|
261
|
+
({"nbf": int(time.time()) + 3600}, "token not yet valid"),
|
|
262
|
+
({"iat": int(time.time()) + 3600}, "token not yet valid"),
|
|
263
|
+
({"aud": "someone-else"}, "wrong audience"),
|
|
264
|
+
({"aud": None}, "missing required claim"),
|
|
265
|
+
({"iss": "https://evil.test/"}, "wrong issuer"),
|
|
266
|
+
({"iss": None}, "missing required claim"),
|
|
267
|
+
({"exp": None}, "missing required claim"),
|
|
268
|
+
({"sub": None}, "missing principal claim"),
|
|
269
|
+
({"sub": ""}, "invalid principal claim"),
|
|
270
|
+
({"sub": "a\x00b"}, "invalid principal claim"),
|
|
271
|
+
({"sub": "x" * 300}, "invalid principal claim"),
|
|
272
|
+
({"sub": True}, "invalid token"), # PyJWT: `sub` must be a string
|
|
273
|
+
],
|
|
274
|
+
)
|
|
275
|
+
async def test_claim_checks_reject_with_a_bearer_challenge(
|
|
276
|
+
claims: dict[str, Any], reason: str
|
|
277
|
+
) -> None:
|
|
278
|
+
policy = JwtPolicy(_settings())
|
|
279
|
+
error = await _reject(policy, _token(RSA_A, **claims), reason)
|
|
280
|
+
assert 'error="invalid_token"' in error.headers["WWW-Authenticate"]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
async def test_leeway_accepts_small_clock_skew() -> None:
|
|
284
|
+
policy = JwtPolicy(_settings(AUTH_JWT_LEEWAY_S="120"))
|
|
285
|
+
now = int(time.time())
|
|
286
|
+
token = _token(RSA_A, exp=now - 60, nbf=now + 60, iat=now + 60)
|
|
287
|
+
assert (await policy.authenticate(_request(token))).id == "user-1"
|
|
288
|
+
strict = JwtPolicy(_settings(AUTH_JWT_LEEWAY_S="0"))
|
|
289
|
+
await _reject(strict, token)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def test_several_audiences_and_an_audience_list_in_the_token() -> None:
|
|
293
|
+
policy = JwtPolicy(_settings(AUTH_JWT_AUDIENCE="other, agent-api"))
|
|
294
|
+
token = _token(RSA_A, aud=["unrelated", AUDIENCE])
|
|
295
|
+
assert (await policy.authenticate(_request(token))).id == "user-1"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def test_missing_or_non_bearer_credentials_get_a_plain_challenge() -> None:
|
|
299
|
+
policy = JwtPolicy(_settings())
|
|
300
|
+
for request in (_request(None), _request(_token(RSA_A), scheme="Basic"), _request("")):
|
|
301
|
+
with pytest.raises(HTTPException) as exc:
|
|
302
|
+
await policy.authenticate(request)
|
|
303
|
+
assert exc.value.status_code == 401
|
|
304
|
+
assert exc.value.headers == {"WWW-Authenticate": "Bearer"}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@pytest.mark.parametrize(
|
|
308
|
+
("token", "reason"),
|
|
309
|
+
[
|
|
310
|
+
("not-a-jwt", "malformed token"),
|
|
311
|
+
("a.b.c", "malformed token"),
|
|
312
|
+
("x" * 20_000, "token too large"),
|
|
313
|
+
],
|
|
314
|
+
)
|
|
315
|
+
async def test_malformed_tokens(token: str, reason: str) -> None:
|
|
316
|
+
await _reject(JwtPolicy(_settings()), token, reason)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# --- algorithm confusion ------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
async def test_alg_none_is_never_accepted() -> None:
|
|
323
|
+
policy = JwtPolicy(_settings())
|
|
324
|
+
for alg in ("none", "None", "NONE"):
|
|
325
|
+
await _reject(policy, _forge({"alg": alg, "kid": "a"}, _claims()), "algorithm not allowed")
|
|
326
|
+
# ...not even when the allow-list tries to include it.
|
|
327
|
+
settings = _settings(AUTH_JWT_ALGORITHMS="RS256,none")
|
|
328
|
+
assert any("never include 'none'" in p for p in settings.problems)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def test_hs256_signed_with_the_public_key_is_refused() -> None:
|
|
332
|
+
"""The classic confusion: HMAC over the (public) PEM text the server trusts."""
|
|
333
|
+
pem = _pem(RSA_A).encode()
|
|
334
|
+
forged = _forge({"alg": "HS256", "typ": "JWT", "kid": "a"}, _claims(), secret=pem)
|
|
335
|
+
await _reject(JwtPolicy(_settings()), forged, "algorithm not allowed")
|
|
336
|
+
# Even with HS256 allowed, a token is checked with AUTH_JWT_SECRET only.
|
|
337
|
+
secret = "s" * 40
|
|
338
|
+
policy = JwtPolicy(
|
|
339
|
+
_settings(
|
|
340
|
+
AUTH_JWT_ALGORITHMS="RS256,HS256", AUTH_JWT_ALLOW_HS="true", AUTH_JWT_SECRET=secret
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
assert policy.startup_problems() == []
|
|
344
|
+
await _reject(policy, forged, "invalid signature")
|
|
345
|
+
assert (await policy.authenticate(_request(_token(secret, "HS256")))).id == "user-1"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def test_a_token_is_only_checked_with_a_key_of_its_own_family(jwks: JwksServer) -> None:
|
|
349
|
+
# RS256 token, but only ES256 is allowed.
|
|
350
|
+
es_only = JwtPolicy(_settings(AUTH_JWT_PUBLIC_KEY=_pem(EC_P256), AUTH_JWT_ALGORITHMS="ES256"))
|
|
351
|
+
await _reject(es_only, _token(RSA_A), "algorithm not allowed")
|
|
352
|
+
# ES256 header over an EC key on the wrong curve.
|
|
353
|
+
both = JwtPolicy(
|
|
354
|
+
_settings(AUTH_JWT_PUBLIC_KEY=_pem(EC_P384), AUTH_JWT_ALGORITHMS="ES256,ES384")
|
|
355
|
+
)
|
|
356
|
+
await _reject(both, _token(EC_P256, "ES256"))
|
|
357
|
+
# A JWKS entry whose kid matches but whose type does not.
|
|
358
|
+
jwks.keys = [_jwk(RSA_A, "shared-kid")]
|
|
359
|
+
policy = _jwks_policy(jwks)
|
|
360
|
+
await _reject(policy, _token(EC_P256, "ES256", kid="shared-kid"), "unknown signing key")
|
|
361
|
+
# A JWK pinned to RS512 does not verify an RS256 token.
|
|
362
|
+
jwks.keys = [_jwk(RSA_A, "a", alg="RS512")]
|
|
363
|
+
await _reject(_jwks_policy(jwks), _token(RSA_A), "unknown signing key")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def test_symmetric_and_encryption_keys_in_a_jwks_are_ignored(jwks: JwksServer) -> None:
|
|
367
|
+
secret = b"k" * 40
|
|
368
|
+
jwks.keys = [
|
|
369
|
+
{"kty": "oct", "kid": "sym", "k": _b64(secret), "alg": "HS256"},
|
|
370
|
+
_jwk(RSA_A, "enc", use="enc"),
|
|
371
|
+
_jwk(RSA_A, "a"),
|
|
372
|
+
]
|
|
373
|
+
policy = _jwks_policy(jwks, AUTH_JWT_ALGORITHMS="RS256")
|
|
374
|
+
forged = _forge({"alg": "HS256", "kid": "sym"}, _claims(), secret=secret)
|
|
375
|
+
await _reject(policy, forged, "algorithm not allowed")
|
|
376
|
+
await _reject(policy, _token(RSA_A, kid="enc"), "unknown signing key")
|
|
377
|
+
assert (await policy.authenticate(_request(_token(RSA_A, kid="a")))).id == "user-1"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
async def test_keys_named_or_embedded_in_the_token_are_never_used(jwks: JwksServer) -> None:
|
|
381
|
+
attacker = _rsa()
|
|
382
|
+
policy = _jwks_policy(jwks)
|
|
383
|
+
for header in (
|
|
384
|
+
{"kid": "a", "jwk": _jwk(attacker, "a")},
|
|
385
|
+
{"kid": "a", "jku": "https://evil.test/jwks.json"},
|
|
386
|
+
{"kid": "a", "x5u": "https://evil.test/cert.pem"},
|
|
387
|
+
):
|
|
388
|
+
token = jwt.encode(_claims(), attacker, algorithm="RS256", headers=header)
|
|
389
|
+
await _reject(policy, token, "invalid signature")
|
|
390
|
+
await _reject(policy, _token(attacker, kid="../../etc/passwd"), "unknown signing key")
|
|
391
|
+
assert jwks.hits == 1 # the attacker's URLs were never fetched
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def test_a_redirecting_jwks_url_is_not_followed(jwks: JwksServer) -> None:
|
|
395
|
+
jwks.status = 302
|
|
396
|
+
with pytest.raises(HTTPException) as exc:
|
|
397
|
+
await _jwks_policy(jwks).authenticate(_request(_token(RSA_A)))
|
|
398
|
+
assert exc.value.status_code == 503
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
async def test_unsupported_critical_header_is_refused() -> None:
|
|
402
|
+
token = jwt.encode(_claims(), RSA_A, algorithm="RS256", headers={"kid": "a", "crit": ["exp"]})
|
|
403
|
+
# Refused whichever layer notices first (PyJWT's header check or the policy's).
|
|
404
|
+
await _reject(JwtPolicy(_settings()), token)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# --- JWKS caching and rotation --------------------------------------------------
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
async def test_the_jwks_is_fetched_once_and_cached(jwks: JwksServer) -> None:
|
|
411
|
+
policy = _jwks_policy(jwks)
|
|
412
|
+
for _ in range(5):
|
|
413
|
+
await policy.authenticate(_request(_token(RSA_A)))
|
|
414
|
+
assert jwks.hits == 1
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
async def test_an_unknown_kid_refetches_once_and_picks_up_a_rotated_key(jwks: JwksServer) -> None:
|
|
418
|
+
clock = FakeClock()
|
|
419
|
+
policy = _jwks_policy(jwks)
|
|
420
|
+
policy.jwks = JwksCache(jwks.url, 300, clock=clock)
|
|
421
|
+
await policy.authenticate(_request(_token(RSA_A)))
|
|
422
|
+
assert jwks.hits == 1
|
|
423
|
+
# The issuer rotates to key b; a token signed with b triggers one refetch.
|
|
424
|
+
jwks.keys = [_jwk(RSA_A, "a"), _jwk(RSA_B, "b")]
|
|
425
|
+
clock.now += 31
|
|
426
|
+
assert (await policy.authenticate(_request(_token(RSA_B, kid="b")))).id == "user-1"
|
|
427
|
+
assert jwks.hits == 2
|
|
428
|
+
# Unknown kids right after that do not reach the issuer (rate limit).
|
|
429
|
+
for kid in ("x1", "x2", "x3"):
|
|
430
|
+
await _reject(policy, _token(RSA_B, kid=kid), "unknown signing key")
|
|
431
|
+
assert jwks.hits == 2
|
|
432
|
+
# After the interval, one more refetch is allowed.
|
|
433
|
+
clock.now += 31
|
|
434
|
+
await _reject(policy, _token(RSA_B, kid="x4"), "unknown signing key")
|
|
435
|
+
assert jwks.hits == 3
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
async def test_concurrent_tokens_with_a_rotated_key_share_one_refetch(jwks: JwksServer) -> None:
|
|
439
|
+
"""Every request carrying the new key id waits for the one refetch and uses its result."""
|
|
440
|
+
clock = FakeClock()
|
|
441
|
+
policy = _jwks_policy(jwks)
|
|
442
|
+
policy.jwks = JwksCache(jwks.url, 300, clock=clock)
|
|
443
|
+
await policy.authenticate(_request(_token(RSA_A)))
|
|
444
|
+
jwks.keys = [_jwk(RSA_A, "a"), _jwk(RSA_B, "b")]
|
|
445
|
+
clock.now += 31
|
|
446
|
+
token = _token(RSA_B, kid="b")
|
|
447
|
+
results = await asyncio.gather(
|
|
448
|
+
*(policy.authenticate(_request(token)) for _ in range(8)), return_exceptions=True
|
|
449
|
+
)
|
|
450
|
+
assert [getattr(r, "id", r) for r in results] == ["user-1"] * 8
|
|
451
|
+
assert jwks.hits == 2
|
|
452
|
+
# Random unknown key ids at once still cost the issuer at most one fetch.
|
|
453
|
+
clock.now += 31
|
|
454
|
+
results = await asyncio.gather(
|
|
455
|
+
*(policy.authenticate(_request(_token(RSA_B, kid=f"x{i}"))) for i in range(8)),
|
|
456
|
+
return_exceptions=True,
|
|
457
|
+
)
|
|
458
|
+
assert all(isinstance(r, HTTPException) and r.status_code == 401 for r in results)
|
|
459
|
+
assert jwks.hits == 3
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
async def test_a_hanging_issuer_never_stalls_requests_the_cached_keys_can_verify() -> None:
|
|
463
|
+
"""Stale while revalidate: expired keys keep serving while one bounded refresh runs."""
|
|
464
|
+
clock = FakeClock()
|
|
465
|
+
cache = JwksCache(
|
|
466
|
+
"https://issuer.test/jwks", 300, timeout_s=0.1, stale_grace_s=600, clock=clock
|
|
467
|
+
)
|
|
468
|
+
fetches = 0
|
|
469
|
+
|
|
470
|
+
async def fetch() -> list[dict[str, Any]]:
|
|
471
|
+
nonlocal fetches
|
|
472
|
+
fetches += 1
|
|
473
|
+
if fetches == 1:
|
|
474
|
+
return [_jwk(RSA_A, "a")]
|
|
475
|
+
await asyncio.sleep(3600) # the issuer stops answering
|
|
476
|
+
return []
|
|
477
|
+
|
|
478
|
+
cache._fetch = fetch # type: ignore[method-assign]
|
|
479
|
+
assert [k["kid"] for k in await cache.keys()] == ["a"]
|
|
480
|
+
clock.now += 301 # expired, within the grace period
|
|
481
|
+
started = time.monotonic()
|
|
482
|
+
for _ in range(20):
|
|
483
|
+
assert [k["kid"] for k in await cache.keys()] == ["a"]
|
|
484
|
+
assert time.monotonic() - started < 0.05 # none of them waited for the issuer
|
|
485
|
+
await asyncio.sleep(0)
|
|
486
|
+
assert fetches == 2 # one background refresh, not one per request
|
|
487
|
+
await cache.idle() # it gives up at its deadline (2 x timeout_s)
|
|
488
|
+
assert [k["kid"] for k in await cache.keys()] == ["a"]
|
|
489
|
+
assert fetches == 2 # rate-limited: no new fetch yet, the cached keys still serve
|
|
490
|
+
clock.now += 600 # past the grace period: requests now wait for a (bounded) fetch
|
|
491
|
+
started = time.monotonic()
|
|
492
|
+
with pytest.raises(JwksUnavailable):
|
|
493
|
+
await cache.keys()
|
|
494
|
+
assert 0.15 < time.monotonic() - started < 2 and fetches == 3
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
async def test_a_token_without_kid_needs_an_unambiguous_key(jwks: JwksServer) -> None:
|
|
498
|
+
policy = _jwks_policy(jwks)
|
|
499
|
+
assert (await policy.authenticate(_request(_token(RSA_A, kid=None)))).id == "user-1"
|
|
500
|
+
jwks.keys = [_jwk(RSA_A, "a"), _jwk(RSA_B, "b")]
|
|
501
|
+
other = _jwks_policy(jwks)
|
|
502
|
+
await _reject(other, _token(RSA_A, kid=None), "unknown signing key")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
async def test_an_unreachable_jwks_serves_stale_keys_then_fails_closed(jwks: JwksServer) -> None:
|
|
506
|
+
clock = FakeClock()
|
|
507
|
+
policy = _jwks_policy(jwks)
|
|
508
|
+
policy.jwks = JwksCache(jwks.url, 300, stale_grace_s=600, clock=clock)
|
|
509
|
+
await policy.authenticate(_request(_token(RSA_A)))
|
|
510
|
+
jwks.status = 500
|
|
511
|
+
clock.now += 400 # cache expired, refresh fails: the last good keys still verify
|
|
512
|
+
assert (await policy.authenticate(_request(_token(RSA_A)))).id == "user-1"
|
|
513
|
+
await policy.jwks.idle() # the refresh ran in the background
|
|
514
|
+
assert jwks.hits == 2
|
|
515
|
+
assert (await policy.authenticate(_request(_token(RSA_A)))).id == "user-1"
|
|
516
|
+
clock.now += 600 # beyond the grace period: 503, never "no auth"
|
|
517
|
+
with pytest.raises(HTTPException) as exc:
|
|
518
|
+
await policy.authenticate(_request(_token(RSA_A)))
|
|
519
|
+
assert exc.value.status_code == 503
|
|
520
|
+
jwks.status = 200
|
|
521
|
+
clock.now += 31
|
|
522
|
+
assert (await policy.authenticate(_request(_token(RSA_A)))).id == "user-1"
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
async def test_a_stalled_fetch_has_a_deadline_and_a_waiter_leaving_does_not_cancel_it() -> None:
|
|
526
|
+
async def stall() -> list[dict[str, Any]]:
|
|
527
|
+
await asyncio.sleep(3600)
|
|
528
|
+
return []
|
|
529
|
+
|
|
530
|
+
cache = JwksCache("https://issuer.test/jwks", 300, timeout_s=0.05)
|
|
531
|
+
cache._fetch = stall # type: ignore[method-assign]
|
|
532
|
+
with pytest.raises(JwksUnavailable):
|
|
533
|
+
await cache.keys() # gives up after 2 x timeout_s
|
|
534
|
+
shared = JwksCache("https://issuer.test/jwks", 300)
|
|
535
|
+
fetches = 0
|
|
536
|
+
|
|
537
|
+
async def slow() -> list[dict[str, Any]]:
|
|
538
|
+
nonlocal fetches
|
|
539
|
+
fetches += 1
|
|
540
|
+
await asyncio.sleep(0.1)
|
|
541
|
+
return [_jwk(RSA_A, "a")]
|
|
542
|
+
|
|
543
|
+
shared._fetch = slow # type: ignore[method-assign]
|
|
544
|
+
leaving = asyncio.ensure_future(shared.keys())
|
|
545
|
+
await asyncio.sleep(0.02)
|
|
546
|
+
leaving.cancel() # the client went away
|
|
547
|
+
with pytest.raises(asyncio.CancelledError):
|
|
548
|
+
await leaving
|
|
549
|
+
# The fetch carries on for the requests still waiting, which share it.
|
|
550
|
+
assert [k["kid"] for k in await shared.keys()] == ["a"] and fetches == 1
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@pytest.mark.parametrize(
|
|
554
|
+
"body",
|
|
555
|
+
[
|
|
556
|
+
b"not json",
|
|
557
|
+
b'{"keys": "nope"}',
|
|
558
|
+
b'{"keys": []}',
|
|
559
|
+
b"[]",
|
|
560
|
+
b'{"keys": [' + b"1," * 600_000 + b"1]}",
|
|
561
|
+
],
|
|
562
|
+
)
|
|
563
|
+
async def test_a_bad_jwks_response_is_not_used(jwks: JwksServer, body: bytes) -> None:
|
|
564
|
+
jwks.body = body
|
|
565
|
+
policy = _jwks_policy(jwks)
|
|
566
|
+
with pytest.raises(HTTPException) as exc:
|
|
567
|
+
await policy.authenticate(_request(_token(RSA_A)))
|
|
568
|
+
assert exc.value.status_code == 503
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# --- settings ---------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@pytest.mark.parametrize(
|
|
575
|
+
("env", "problem"),
|
|
576
|
+
[
|
|
577
|
+
({"AUTH_JWT_ALGORITHMS": "none"}, "never include 'none'"),
|
|
578
|
+
({"AUTH_JWT_ALGORITHMS": "RS256,XS999"}, "unsupported algorithm"),
|
|
579
|
+
({"AUTH_JWT_ALGORITHMS": ","}, "lists no algorithm"),
|
|
580
|
+
({"AUTH_JWT_ALGORITHMS": "HS256"}, "AUTH_JWT_ALLOW_HS=true"),
|
|
581
|
+
({"AUTH_JWT_ALGORITHMS": "HS256", "AUTH_JWT_ALLOW_HS": "true"}, "needs AUTH_JWT_SECRET"),
|
|
582
|
+
(
|
|
583
|
+
{
|
|
584
|
+
"AUTH_JWT_ALGORITHMS": "HS256",
|
|
585
|
+
"AUTH_JWT_ALLOW_HS": "true",
|
|
586
|
+
"AUTH_JWT_SECRET": "short",
|
|
587
|
+
},
|
|
588
|
+
"at least 32 bytes",
|
|
589
|
+
),
|
|
590
|
+
(
|
|
591
|
+
{
|
|
592
|
+
"AUTH_JWT_ALGORITHMS": "HS256",
|
|
593
|
+
"AUTH_JWT_ALLOW_HS": "true",
|
|
594
|
+
"AUTH_JWT_SECRET": _pem(RSA_A),
|
|
595
|
+
},
|
|
596
|
+
"not a PEM key",
|
|
597
|
+
),
|
|
598
|
+
({"AUTH_JWT_PUBLIC_KEY": ""}, "no verification key"),
|
|
599
|
+
({"AUTH_JWT_JWKS_URL": "https://issuer.test/jwks"}, "not both"),
|
|
600
|
+
(
|
|
601
|
+
{"AUTH_JWT_PUBLIC_KEY": "-----BEGIN PUBLIC KEY-----\nnope\n-----END PUBLIC KEY-----"},
|
|
602
|
+
"not a PEM",
|
|
603
|
+
),
|
|
604
|
+
(
|
|
605
|
+
{"AUTH_JWT_PUBLIC_KEY": "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----"},
|
|
606
|
+
"private key",
|
|
607
|
+
),
|
|
608
|
+
({"AUTH_JWT_ALGORITHMS": "ES256"}, "verifies none of"),
|
|
609
|
+
({"AUTH_JWT_ISSUER": ""}, "AUTH_JWT_ISSUER is required"),
|
|
610
|
+
({"AUTH_JWT_AUDIENCE": " , "}, "AUTH_JWT_AUDIENCE is required"),
|
|
611
|
+
({"AUTH_JWT_LEEWAY_S": "soon"}, "whole number"),
|
|
612
|
+
({"AUTH_JWT_LEEWAY_S": "-5"}, "between 0 and 600"),
|
|
613
|
+
({"AUTH_JWT_JWKS_CACHE_S": "0"}, "between 1 and 86400"),
|
|
614
|
+
({"AUTH_JWT_ROLES_CLAIM": "realm_access..roles"}, "dotted path"),
|
|
615
|
+
],
|
|
616
|
+
)
|
|
617
|
+
def test_settings_problems(env: dict[str, str], problem: str) -> None:
|
|
618
|
+
problems = _settings(**env).problems
|
|
619
|
+
assert any(problem in p for p in problems), problems
|
|
620
|
+
# A problem never quotes key material.
|
|
621
|
+
assert not any("BEGIN" in p for p in problems)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
@pytest.mark.parametrize(
|
|
625
|
+
("url", "extra", "ok"),
|
|
626
|
+
[
|
|
627
|
+
("https://issuer.test/jwks", {}, True),
|
|
628
|
+
("http://issuer.test/jwks", {}, False),
|
|
629
|
+
("http://issuer.test/jwks", {"AUTH_JWT_JWKS_ALLOW_HTTP": "true"}, True),
|
|
630
|
+
("http://issuer.test/jwks", {"APP_ENV": "dev"}, True),
|
|
631
|
+
("http://127.0.0.1:9/jwks", {}, True),
|
|
632
|
+
("http://localhost:9/jwks", {}, True),
|
|
633
|
+
("ftp://issuer.test/jwks", {}, False),
|
|
634
|
+
("https://user:pw@issuer.test/jwks", {}, False),
|
|
635
|
+
("issuer.test/jwks", {}, False),
|
|
636
|
+
],
|
|
637
|
+
)
|
|
638
|
+
def test_jwks_url_must_be_https_outside_dev(url: str, extra: dict[str, str], ok: bool) -> None:
|
|
639
|
+
problems = _settings(AUTH_JWT_PUBLIC_KEY=None, AUTH_JWT_JWKS_URL=url, **extra).problems # type: ignore[arg-type]
|
|
640
|
+
assert (problems == []) is ok, problems
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def test_issuer_and_audience_are_optional_only_under_dev() -> None:
|
|
644
|
+
dev = _settings(APP_ENV="dev", AUTH_JWT_ISSUER="", AUTH_JWT_AUDIENCE="")
|
|
645
|
+
assert dev.problems == []
|
|
646
|
+
assert len(dev.warnings) == 2
|
|
647
|
+
for app_env in ("prod", "staging", "", "DEV", "Dev", " dev "): # only exactly `dev` relaxes
|
|
648
|
+
assert (
|
|
649
|
+
len(_settings(APP_ENV=app_env, AUTH_JWT_ISSUER="", AUTH_JWT_AUDIENCE="").problems) == 2
|
|
650
|
+
), app_env
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
async def test_unchecked_issuer_and_audience_under_dev_accept_any() -> None:
|
|
654
|
+
policy = JwtPolicy(_settings(APP_ENV="dev", AUTH_JWT_ISSUER="", AUTH_JWT_AUDIENCE=""))
|
|
655
|
+
token = _token(RSA_A, iss="https://anyone.test/", aud="anything")
|
|
656
|
+
assert (await policy.authenticate(_request(token))).id == "user-1"
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def test_algorithm_names_are_normalised() -> None:
|
|
660
|
+
settings = _settings(AUTH_JWT_ALGORITHMS="rs256, RS256 ,ps256")
|
|
661
|
+
assert settings.algorithms == ("RS256", "PS256") and settings.problems == []
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
# --- claims -----------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
@pytest.mark.parametrize(
|
|
668
|
+
("claims", "setting", "roles"),
|
|
669
|
+
[
|
|
670
|
+
({"realm_access": {"roles": ["admin", "user"]}}, "realm_access.roles", ["admin", "user"]),
|
|
671
|
+
({"scope": "read write,admin read"}, "scope", ["read", "write", "admin"]),
|
|
672
|
+
({"https://example.com/roles": ["ops"]}, "https://example.com/roles", ["ops"]),
|
|
673
|
+
({"roles": "solo"}, "roles", ["solo"]),
|
|
674
|
+
(
|
|
675
|
+
{"roles": ["ok", 7, None, {"x": 1}, " spaced ", "a,b", "bad\nrole"]},
|
|
676
|
+
"roles",
|
|
677
|
+
["ok", "spaced"],
|
|
678
|
+
),
|
|
679
|
+
({"roles": {"not": "a list"}}, "roles", []),
|
|
680
|
+
({}, "roles", []),
|
|
681
|
+
({"realm_access": "flat"}, "realm_access.roles", []),
|
|
682
|
+
],
|
|
683
|
+
)
|
|
684
|
+
def test_roles_claim_forms(claims: dict[str, Any], setting: str, roles: list[str]) -> None:
|
|
685
|
+
policy = JwtPolicy(_settings(AUTH_JWT_ROLES_CLAIM=setting))
|
|
686
|
+
principal = policy.principal_from_claims({"sub": "u", **claims})
|
|
687
|
+
assert principal.roles == roles
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
async def test_principal_claim_setting() -> None:
|
|
691
|
+
policy = JwtPolicy(_settings(AUTH_JWT_PRINCIPAL_CLAIM="email"))
|
|
692
|
+
principal = await policy.authenticate(_request(_token(RSA_A, email="ada@example.com")))
|
|
693
|
+
assert principal.id == "ada@example.com"
|
|
694
|
+
await _reject(policy, _token(RSA_A), "missing principal claim")
|
|
695
|
+
nested = JwtPolicy(_settings(AUTH_JWT_PRINCIPAL_CLAIM="user.id"))
|
|
696
|
+
assert (await nested.authenticate(_request(_token(RSA_A, user={"id": 42})))).id == "42"
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# --- no token contents in logs or errors --------------------------------------------
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
async def test_token_contents_never_reach_logs_or_errors(
|
|
703
|
+
jwks: JwksServer, caplog: pytest.LogCaptureFixture
|
|
704
|
+
) -> None:
|
|
705
|
+
secret_sub = "sub-7f3a9c-secret"
|
|
706
|
+
tokens = [
|
|
707
|
+
_token(RSA_A, sub=secret_sub, exp=int(time.time()) - 999),
|
|
708
|
+
_token(RSA_A, sub=secret_sub, aud="elsewhere"),
|
|
709
|
+
_token(RSA_B, sub=secret_sub, kid="unknown-kid-9d2e"),
|
|
710
|
+
_token(RSA_B, sub=secret_sub, kid="a"),
|
|
711
|
+
_forge({"alg": "none", "kid": "a"}, _claims(sub=secret_sub)),
|
|
712
|
+
]
|
|
713
|
+
policy = _jwks_policy(jwks)
|
|
714
|
+
details = []
|
|
715
|
+
with caplog.at_level(logging.DEBUG):
|
|
716
|
+
for token in tokens:
|
|
717
|
+
details.append(str((await _reject(policy, token)).detail))
|
|
718
|
+
jwks.status = 500
|
|
719
|
+
policy.jwks = JwksCache(jwks.url, 1, refetch_interval_s=0)
|
|
720
|
+
with pytest.raises(HTTPException):
|
|
721
|
+
await policy.authenticate(_request(tokens[1]))
|
|
722
|
+
text = caplog.text + " ".join(details)
|
|
723
|
+
for token in tokens:
|
|
724
|
+
for part in token.split("."):
|
|
725
|
+
if len(part) > 8:
|
|
726
|
+
assert part not in text
|
|
727
|
+
assert secret_sub not in text and "unknown-kid-9d2e" not in text
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
# --- wiring: selection, startup, fastapi dependency, LangGraph Server auth ---------
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
@pytest.fixture
|
|
734
|
+
def jwt_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[pytest.MonkeyPatch]:
|
|
735
|
+
monkeypatch.setenv("AUTH_POLICY", "jwt")
|
|
736
|
+
monkeypatch.setenv("APP_ENV", "prod")
|
|
737
|
+
monkeypatch.setenv("AUTH_JWT_PUBLIC_KEY", _pem(RSA_A))
|
|
738
|
+
monkeypatch.setenv("AUTH_JWT_ISSUER", ISSUER)
|
|
739
|
+
monkeypatch.setenv("AUTH_JWT_AUDIENCE", AUDIENCE)
|
|
740
|
+
monkeypatch.delenv("AUTH_JWT_JWKS_URL", raising=False)
|
|
741
|
+
monkeypatch.delenv("AUTH_JWT_ALGORITHMS", raising=False)
|
|
742
|
+
reset_policy_cache()
|
|
743
|
+
yield monkeypatch
|
|
744
|
+
reset_policy_cache()
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
async def test_selected_by_auth_policy_and_used_by_the_fastapi_dependency(jwt_env) -> None:
|
|
748
|
+
assert isinstance(get_policy(), JwtPolicy)
|
|
749
|
+
check_startup()
|
|
750
|
+
principal = await require("chat.send")(_request(_token(RSA_A)))
|
|
751
|
+
assert principal.id == "user-1"
|
|
752
|
+
with pytest.raises(HTTPException) as exc:
|
|
753
|
+
await require("thread.read")(_request(_token(RSA_B)))
|
|
754
|
+
assert exc.value.status_code == 401 and "WWW-Authenticate" in exc.value.headers
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
async def test_startup_fails_closed_outside_dev_and_requests_get_503(
|
|
758
|
+
jwt_env, caplog: pytest.LogCaptureFixture
|
|
759
|
+
) -> None:
|
|
760
|
+
jwt_env.delenv("AUTH_JWT_AUDIENCE")
|
|
761
|
+
reset_policy_cache()
|
|
762
|
+
with pytest.raises(RuntimeError, match="AUTH_JWT_AUDIENCE is required"):
|
|
763
|
+
check_startup()
|
|
764
|
+
for action in ACTIONS:
|
|
765
|
+
with pytest.raises(HTTPException) as exc:
|
|
766
|
+
await require(action)(_request(_token(RSA_A)))
|
|
767
|
+
assert exc.value.status_code == 503 and "AUTH_POLICY=jwt" in exc.value.detail
|
|
768
|
+
# Under dev a key-less configuration is logged, not fatal; requests still get 503.
|
|
769
|
+
jwt_env.setenv("APP_ENV", "dev")
|
|
770
|
+
jwt_env.delenv("AUTH_JWT_PUBLIC_KEY")
|
|
771
|
+
reset_policy_cache()
|
|
772
|
+
with caplog.at_level(logging.ERROR):
|
|
773
|
+
check_startup()
|
|
774
|
+
assert "no verification key" in caplog.text
|
|
775
|
+
with pytest.raises(HTTPException) as exc:
|
|
776
|
+
await require("chat.send")(_request(_token(RSA_A)))
|
|
777
|
+
assert exc.value.status_code == 503
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
async def test_langgraph_server_auth_uses_the_same_policy(jwt_env) -> None:
|
|
781
|
+
from langgraph_sdk import Auth
|
|
782
|
+
|
|
783
|
+
auth = build_sdk_auth()
|
|
784
|
+
authenticate = auth._authenticate_handler
|
|
785
|
+
user = await authenticate(request=_request(_token(RSA_A, roles="admin ops")))
|
|
786
|
+
assert user["identity"] == "user-1"
|
|
787
|
+
assert "role:admin" in user["permissions"] and "role:ops" in user["permissions"]
|
|
788
|
+
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
|
789
|
+
await authenticate(request=_request(_token(RSA_A, iss="https://evil.test/")))
|
|
790
|
+
assert exc.value.status_code == 401
|
|
791
|
+
assert "invalid_token" in dict(exc.value.headers or {})["WWW-Authenticate"]
|
|
792
|
+
# A misconfigured jwt policy stops the server from loading its auth outside dev.
|
|
793
|
+
jwt_env.delenv("AUTH_JWT_ISSUER")
|
|
794
|
+
reset_policy_cache()
|
|
795
|
+
with pytest.raises(RuntimeError, match="AUTH_JWT_ISSUER"):
|
|
796
|
+
build_sdk_auth()
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def test_the_delegation_defaults_on_record() -> None:
|
|
800
|
+
"""Defaults decided for 0.3 (P1 reads them): the actor is the RFC 8693 `act` claim
|
|
801
|
+
(empty turns delegation off, as in 0.2), and no delegated caller is allowed until
|
|
802
|
+
`AUTH_ALLOWED_ACTORS` lists it."""
|
|
803
|
+
assert DEFAULT_JWT_ACTOR_CLAIM == "act"
|
|
804
|
+
assert DEFAULT_ALLOWED_ACTORS == frozenset()
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# --- delegation (0.3): an agent presenting a user's token ---------------------------------
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _delegated(token: str) -> Any:
|
|
811
|
+
return JwtPolicy(_settings()).authenticate(_request(token))
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
async def test_act_chain_is_read_current_actor_first() -> None:
|
|
815
|
+
act = {"sub": "billing", "act": {"sub": "concierge"}}
|
|
816
|
+
principal = await _delegated(_token(RSA_A, act=act, azp="billing"))
|
|
817
|
+
assert principal.id == "user-1" # the subject, unchanged
|
|
818
|
+
assert principal.actor is not None and principal.delegated
|
|
819
|
+
assert (principal.actor.id, principal.actor.chain) == ("billing", ("billing", "concierge"))
|
|
820
|
+
assert principal.actor.client == "billing"
|
|
821
|
+
assert principal.owner_key() == "user-1\x1fbilling"
|
|
822
|
+
assert principal.hashed_id() == Principal(id="user-1").hashed_id() # subject-only
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
@pytest.mark.parametrize(
|
|
826
|
+
"act",
|
|
827
|
+
[
|
|
828
|
+
None, # present but null
|
|
829
|
+
"concierge",
|
|
830
|
+
{},
|
|
831
|
+
{"sub": 7},
|
|
832
|
+
{"sub": ""},
|
|
833
|
+
{"sub": "con\x00cierge"},
|
|
834
|
+
{"sub": "x" * 257},
|
|
835
|
+
{"sub": "a", "act": "b"},
|
|
836
|
+
{"sub": "a", "act": None},
|
|
837
|
+
],
|
|
838
|
+
)
|
|
839
|
+
async def test_a_malformed_actor_claim_is_refused_never_read_as_direct(act: Any) -> None:
|
|
840
|
+
claims = _claims()
|
|
841
|
+
claims["act"] = act
|
|
842
|
+
token = jwt.encode(claims, RSA_A, algorithm="RS256", headers={"kid": "a"})
|
|
843
|
+
await _reject(JwtPolicy(_settings()), token, "invalid actor claim")
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
async def test_an_actor_chain_longer_than_eight_is_refused_while_walking() -> None:
|
|
847
|
+
act: dict[str, Any] = {"sub": "a0"}
|
|
848
|
+
for index in range(1, 9):
|
|
849
|
+
act = {"sub": f"a{index}", "act": act}
|
|
850
|
+
await _reject(JwtPolicy(_settings()), _token(RSA_A, act=act), "invalid actor claim")
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def test_direct_clients_make_unlisted_clients_agents() -> None:
|
|
854
|
+
direct = frozenset({"web"})
|
|
855
|
+
|
|
856
|
+
def actor(**claims: Any) -> Any:
|
|
857
|
+
return actor_from_claims(claims, direct_clients=direct, subject=claims.get("sub"))
|
|
858
|
+
|
|
859
|
+
assert actor(sub="alice", azp="web") is None
|
|
860
|
+
assert actor(sub="alice", azp="concierge").id == "client:concierge"
|
|
861
|
+
assert actor(sub="alice", client_id="cli").id == "client:cli" # RFC 9068 client_id
|
|
862
|
+
assert actor(sub="alice").id == "client:?"
|
|
863
|
+
assert actor(sub="concierge", azp="concierge") is None # a service's own token
|
|
864
|
+
assert actor_from_claims({"sub": "alice", "azp": "concierge"}) is None # none listed
|
|
865
|
+
okta = actor_from_claims({"cid": "bot"}, client_claim="cid", direct_clients=direct)
|
|
866
|
+
assert okta is not None and okta.id == "client:bot" and okta.client == "bot"
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
async def test_direct_clients_from_the_environment() -> None:
|
|
870
|
+
policy = JwtPolicy(_settings(AUTH_JWT_DIRECT_CLIENTS="web, mobile"))
|
|
871
|
+
assert (await policy.authenticate(_request(_token(RSA_A, azp="web")))).actor is None
|
|
872
|
+
agent = await policy.authenticate(_request(_token(RSA_A, azp="concierge")))
|
|
873
|
+
assert agent.actor is not None and agent.actor.id == "client:concierge"
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
async def test_an_empty_actor_claim_setting_restores_the_02_reading() -> None:
|
|
877
|
+
policy = JwtPolicy(_settings(AUTH_JWT_ACTOR_CLAIM=""))
|
|
878
|
+
principal = await policy.authenticate(_request(_token(RSA_A, act={"sub": "concierge"})))
|
|
879
|
+
assert principal.actor is None and principal.roles == ["viewer", "support"]
|
|
880
|
+
other = JwtPolicy(_settings(AUTH_JWT_ACTOR_CLAIM="ext.actor"))
|
|
881
|
+
nested = await other.authenticate(_request(_token(RSA_A, ext={"actor": {"sub": "bot"}})))
|
|
882
|
+
assert nested.actor is not None and nested.actor.id == "bot"
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def test_actor_settings_are_checked() -> None:
|
|
886
|
+
assert _settings().actor_claim == DEFAULT_JWT_ACTOR_CLAIM
|
|
887
|
+
assert "AUTH_JWT_ACTOR_CLAIM" in " ".join(_settings(AUTH_JWT_ACTOR_CLAIM="a..b").problems)
|
|
888
|
+
assert "AUTH_JWT_CLIENT_CLAIM" in " ".join(_settings(AUTH_JWT_CLIENT_CLAIM=" ").problems)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
async def test_delegated_roles_filtered(jwt_env) -> None:
|
|
892
|
+
jwt_env.setenv("AUTH_ALLOWED_ACTORS", "concierge")
|
|
893
|
+
token = _token(RSA_A, act={"sub": "concierge"}, roles=["viewer", "support", "ops"])
|
|
894
|
+
principal = await require("chat.send")(_request(token))
|
|
895
|
+
assert principal.actor is not None and principal.roles == []
|
|
896
|
+
jwt_env.setenv("AUTH_DELEGATED_ROLES", "support")
|
|
897
|
+
principal = await require("chat.send")(_request(token))
|
|
898
|
+
assert principal.roles == ["support"]
|
|
899
|
+
direct = await require("chat.send")(_request(_token(RSA_A, roles=["viewer", "ops"])))
|
|
900
|
+
assert direct.roles == ["viewer", "ops"] and direct.actor is None
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
async def test_a_delegated_caller_is_refused_until_it_is_listed(jwt_env) -> None:
|
|
904
|
+
token = _token(RSA_A, act={"sub": "concierge"})
|
|
905
|
+
with pytest.raises(HTTPException) as exc:
|
|
906
|
+
await require("chat.send")(_request(token))
|
|
907
|
+
assert exc.value.status_code == 403
|
|
908
|
+
assert exc.value.detail == (
|
|
909
|
+
"Delegated caller concierge is not allowed here (AUTH_ALLOWED_ACTORS)."
|
|
910
|
+
)
|
|
911
|
+
jwt_env.setenv("AUTH_ALLOWED_ACTORS", "billing, concierge")
|
|
912
|
+
assert (await require("chat.send")(_request(token))).actor.id == "concierge"
|
|
913
|
+
jwt_env.setenv("AUTH_ALLOWED_ACTORS", "*")
|
|
914
|
+
other = _token(RSA_A, act={"sub": "anyone"})
|
|
915
|
+
assert (await require("chat.send")(_request(other))).actor.id == "anyone"
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
async def test_a_chain_deeper_than_the_setting_is_refused(jwt_env) -> None:
|
|
919
|
+
jwt_env.setenv("AUTH_ALLOWED_ACTORS", "*")
|
|
920
|
+
act = {"sub": "d", "act": {"sub": "c", "act": {"sub": "b", "act": {"sub": "a"}}}}
|
|
921
|
+
with pytest.raises(HTTPException) as exc:
|
|
922
|
+
await require("chat.send")(_request(_token(RSA_A, act=act)))
|
|
923
|
+
assert exc.value.status_code == 401
|
|
924
|
+
assert exc.value.detail == "Invalid bearer token: delegation too deep."
|
|
925
|
+
jwt_env.setenv("AUTH_MAX_DELEGATION_DEPTH", "4")
|
|
926
|
+
principal = await require("chat.send")(_request(_token(RSA_A, act=act)))
|
|
927
|
+
assert principal.actor is not None and principal.actor.chain == ("d", "c", "b", "a")
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
async def test_the_actor_is_published_and_a_policy_supplied_one_is_not_trusted(jwt_env) -> None:
|
|
931
|
+
jwt_env.setenv("AUTH_ALLOWED_ACTORS", "concierge")
|
|
932
|
+
principal = await require("chat.send")(_request(_token(RSA_A, act={"sub": "concierge"})))
|
|
933
|
+
assert principal.attributes["@actor"] == {
|
|
934
|
+
"id": "concierge",
|
|
935
|
+
"chain": ["concierge"],
|
|
936
|
+
"client": None,
|
|
937
|
+
}
|
|
938
|
+
assert principal.public_attributes()["@actor"]["id"] == "concierge"
|
|
939
|
+
forged = Principal(id="alice", attributes={"@actor": {"id": "billing"}, "tenant": "t"})
|
|
940
|
+
assert finalize_principal(forged).attributes == {"tenant": "t"}
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
async def test_the_subject_token_is_kept_only_when_an_api_acts_with_it(
|
|
944
|
+
jwt_env, monkeypatch: pytest.MonkeyPatch
|
|
945
|
+
) -> None:
|
|
946
|
+
token = _token(RSA_A)
|
|
947
|
+
principal = await require("chat.send")(_request(token))
|
|
948
|
+
assert "credentials" not in principal.attributes
|
|
949
|
+
monkeypatch.setattr(auth_module, "subject_token_needed", lambda: True)
|
|
950
|
+
principal = await require("chat.send")(_request(token))
|
|
951
|
+
credentials = principal.attributes["credentials"]
|
|
952
|
+
exp = jwt.decode(token, options={"verify_signature": False})["exp"]
|
|
953
|
+
assert credentials == {
|
|
954
|
+
"@subject_token": token,
|
|
955
|
+
"@subject_aud": (AUDIENCE,),
|
|
956
|
+
"@subject_exp": exp,
|
|
957
|
+
}
|
|
958
|
+
assert "credentials" not in principal.public_attributes()
|
|
959
|
+
assert token not in repr(principal)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def test_without_a_policy_no_subject_token_is_needed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
963
|
+
monkeypatch.setenv("API_POLICY_PATH", "/nonexistent/api-policy.yaml")
|
|
964
|
+
assert auth_module.subject_token_needed() is False
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def test_a_warning_when_only_the_act_claim_can_show_delegation(
|
|
968
|
+
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
969
|
+
) -> None:
|
|
970
|
+
monkeypatch.setenv("AUTH_ALLOWED_ACTORS", "concierge")
|
|
971
|
+
with caplog.at_level(logging.WARNING):
|
|
972
|
+
JwtPolicy(_settings())
|
|
973
|
+
assert "set AUTH_JWT_DIRECT_CLIENTS" in caplog.text
|
|
974
|
+
caplog.clear()
|
|
975
|
+
with caplog.at_level(logging.WARNING):
|
|
976
|
+
JwtPolicy(_settings(AUTH_JWT_DIRECT_CLIENTS="web"))
|
|
977
|
+
assert "AUTH_JWT_DIRECT_CLIENTS" not in caplog.text
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
async def test_langgraph_server_auth_carries_the_actor(jwt_env) -> None:
|
|
981
|
+
jwt_env.setenv("AUTH_ALLOWED_ACTORS", "concierge")
|
|
982
|
+
jwt_env.setenv("AUTH_DELEGATED_ROLES", "ops")
|
|
983
|
+
auth = build_sdk_auth()
|
|
984
|
+
authenticate = auth._authenticate_handler
|
|
985
|
+
token = _token(RSA_A, act={"sub": "concierge"}, roles="admin ops")
|
|
986
|
+
user = await authenticate(request=_request(token))
|
|
987
|
+
assert user["identity"] == "user-1"
|
|
988
|
+
assert "actor:concierge" in user["permissions"] and "role:ops" in user["permissions"]
|
|
989
|
+
assert "role:admin" not in user["permissions"]
|
|
990
|
+
direct = await authenticate(request=_request(_token(RSA_A)))
|
|
991
|
+
assert not [p for p in direct["permissions"] if p.startswith("actor:")]
|