alysis-code 0.13.0__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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,1504 @@
|
|
|
1
|
+
"""Durable, protocol-safe OAuth lifecycle primitives for IDE-managed MCP login.
|
|
2
|
+
|
|
3
|
+
This module deliberately does not open a browser, listen on a callback socket,
|
|
4
|
+
perform HTTP requests, or serialize credentials. It owns the security-sensitive
|
|
5
|
+
state machine around those operations while callers supply the UI and token
|
|
6
|
+
exchange adapters.
|
|
7
|
+
|
|
8
|
+
Only short-lived flow material is stored in the SQLite registry. Access and
|
|
9
|
+
refresh tokens are handed directly to an injected :class:`OAuthTokenVault` and
|
|
10
|
+
are excluded from dataclass representations, public payloads, exceptions, and
|
|
11
|
+
the registry database.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import secrets
|
|
24
|
+
import sqlite3
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
28
|
+
from contextlib import contextmanager
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from enum import Enum
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Protocol, TypeAlias
|
|
33
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
34
|
+
|
|
35
|
+
from ..branding import canonical_user_data_dir, env_get
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"AuthorizationCodeFlowRequest",
|
|
39
|
+
"DeviceCodeFlowRequest",
|
|
40
|
+
"McpOAuthFlowRegistry",
|
|
41
|
+
"OAuthCompletionClaim",
|
|
42
|
+
"OAuthCompletionRejectedError",
|
|
43
|
+
"OAuthExchangeMaterial",
|
|
44
|
+
"OAuthFlowConflictError",
|
|
45
|
+
"OAuthFlowKind",
|
|
46
|
+
"OAuthFlowNotFoundError",
|
|
47
|
+
"OAuthFlowState",
|
|
48
|
+
"OAuthFlowStateError",
|
|
49
|
+
"OAuthFlowStatus",
|
|
50
|
+
"OAuthLifecycleError",
|
|
51
|
+
"OAuthLogoutResult",
|
|
52
|
+
"OAuthTokenSet",
|
|
53
|
+
"OAuthTokenVault",
|
|
54
|
+
"OAuthValidationError",
|
|
55
|
+
"OAuthVaultError",
|
|
56
|
+
"default_oauth_flow_registry_path",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
JsonScalar: TypeAlias = None | bool | int | float | str
|
|
60
|
+
|
|
61
|
+
SCHEMA_VERSION = 1
|
|
62
|
+
DEFAULT_FLOW_TTL_SECONDS = 10 * 60.0
|
|
63
|
+
DEFAULT_COMPLETION_LEASE_SECONDS = 90.0
|
|
64
|
+
DEFAULT_TERMINAL_RETENTION_SECONDS = 24 * 60 * 60.0
|
|
65
|
+
MAX_FLOW_TTL_SECONDS = 30 * 60.0
|
|
66
|
+
MAX_URL_LENGTH = 4096
|
|
67
|
+
MAX_SCOPES = 64
|
|
68
|
+
|
|
69
|
+
_SERVER_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
70
|
+
_PARAM_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,127}$")
|
|
71
|
+
_SCOPE_RE = re.compile(r"^[\x21\x23-\x5B\x5D-\x7E]{1,256}$")
|
|
72
|
+
_FLOW_ID_RE = re.compile(r"^[A-Za-z0-9_-]{32,128}$")
|
|
73
|
+
_ERROR_CODE_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
74
|
+
_RESERVED_AUTH_PARAMS = frozenset(
|
|
75
|
+
{
|
|
76
|
+
"client_id",
|
|
77
|
+
"code_challenge",
|
|
78
|
+
"code_challenge_method",
|
|
79
|
+
"nonce",
|
|
80
|
+
"redirect_uri",
|
|
81
|
+
"response_type",
|
|
82
|
+
"scope",
|
|
83
|
+
"state",
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
_SENSITIVE_URL_PARAM_NAMES = frozenset(
|
|
87
|
+
{
|
|
88
|
+
"access_token",
|
|
89
|
+
"authorization",
|
|
90
|
+
"client_secret",
|
|
91
|
+
"code",
|
|
92
|
+
"device_code",
|
|
93
|
+
"id_token",
|
|
94
|
+
"refresh_token",
|
|
95
|
+
"token",
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class OAuthFlowKind(str, Enum):
|
|
101
|
+
AUTHORIZATION_CODE = "authorization_code"
|
|
102
|
+
DEVICE_CODE = "device_code"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class OAuthFlowState(str, Enum):
|
|
106
|
+
PENDING = "pending"
|
|
107
|
+
COMPLETING = "completing"
|
|
108
|
+
COMPLETED = "completed"
|
|
109
|
+
CANCELLED = "cancelled"
|
|
110
|
+
FAILED = "failed"
|
|
111
|
+
EXPIRED = "expired"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
TERMINAL_STATES = frozenset(
|
|
115
|
+
{
|
|
116
|
+
OAuthFlowState.COMPLETED,
|
|
117
|
+
OAuthFlowState.CANCELLED,
|
|
118
|
+
OAuthFlowState.FAILED,
|
|
119
|
+
OAuthFlowState.EXPIRED,
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class OAuthLifecycleError(RuntimeError):
|
|
125
|
+
"""Base error whose message is safe for logs and protocol error objects."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class OAuthValidationError(OAuthLifecycleError, ValueError):
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class OAuthFlowConflictError(OAuthLifecycleError):
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class OAuthFlowNotFoundError(OAuthLifecycleError):
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class OAuthFlowStateError(OAuthLifecycleError):
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class OAuthCompletionRejectedError(OAuthLifecycleError):
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class OAuthVaultError(OAuthLifecycleError):
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True, slots=True)
|
|
153
|
+
class OAuthTokenSet:
|
|
154
|
+
"""Credentials that must only move between an exchange adapter and a vault."""
|
|
155
|
+
|
|
156
|
+
access_token: str = field(repr=False)
|
|
157
|
+
refresh_token: str | None = field(default=None, repr=False)
|
|
158
|
+
token_type: str = field(default="Bearer", repr=False)
|
|
159
|
+
expires_at: float | None = None
|
|
160
|
+
scopes: tuple[str, ...] = field(default_factory=tuple, repr=False)
|
|
161
|
+
|
|
162
|
+
def __post_init__(self) -> None:
|
|
163
|
+
if not isinstance(self.access_token, str) or not self.access_token.strip():
|
|
164
|
+
raise OAuthValidationError("OAuth access token is missing.")
|
|
165
|
+
if self.refresh_token is not None and (
|
|
166
|
+
not isinstance(self.refresh_token, str) or not self.refresh_token.strip()
|
|
167
|
+
):
|
|
168
|
+
raise OAuthValidationError("OAuth refresh token is invalid.")
|
|
169
|
+
if not isinstance(self.token_type, str) or not self.token_type.strip():
|
|
170
|
+
raise OAuthValidationError("OAuth token type is invalid.")
|
|
171
|
+
if self.expires_at is not None and (
|
|
172
|
+
isinstance(self.expires_at, bool)
|
|
173
|
+
or not isinstance(self.expires_at, (int, float))
|
|
174
|
+
or not math.isfinite(float(self.expires_at))
|
|
175
|
+
):
|
|
176
|
+
raise OAuthValidationError("OAuth token expiry is invalid.")
|
|
177
|
+
object.__setattr__(self, "scopes", _normalize_scopes(self.scopes))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class OAuthTokenVault(Protocol):
|
|
181
|
+
"""Secret-store boundary required by :class:`McpOAuthFlowRegistry`.
|
|
182
|
+
|
|
183
|
+
``binding_id`` is the flow id. Implementations must save the credentials
|
|
184
|
+
atomically and preserve the binding so a restarted registry can determine
|
|
185
|
+
whether an interrupted completion reached the vault.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def store(self, server_id: str, tokens: OAuthTokenSet, *, binding_id: str) -> None: ...
|
|
189
|
+
|
|
190
|
+
def load(self, server_id: str) -> OAuthTokenSet | None: ...
|
|
191
|
+
|
|
192
|
+
def has_binding(self, server_id: str, *, binding_id: str) -> bool: ...
|
|
193
|
+
|
|
194
|
+
def delete(self, server_id: str) -> bool: ...
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(frozen=True, slots=True)
|
|
198
|
+
class AuthorizationCodeFlowRequest:
|
|
199
|
+
server_id: str
|
|
200
|
+
authorization_endpoint: str
|
|
201
|
+
token_endpoint: str
|
|
202
|
+
client_id: str = field(repr=False)
|
|
203
|
+
redirect_uri: str
|
|
204
|
+
scopes: tuple[str, ...] = field(default_factory=tuple)
|
|
205
|
+
extra_authorization_params: Mapping[str, JsonScalar] = field(default_factory=dict, repr=False)
|
|
206
|
+
expires_in: float = DEFAULT_FLOW_TTL_SECONDS
|
|
207
|
+
require_nonce: bool = True
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True, slots=True)
|
|
211
|
+
class DeviceCodeFlowRequest:
|
|
212
|
+
server_id: str
|
|
213
|
+
token_endpoint: str
|
|
214
|
+
client_id: str = field(repr=False)
|
|
215
|
+
device_code: str = field(repr=False)
|
|
216
|
+
user_code: str = field(repr=False)
|
|
217
|
+
verification_uri: str
|
|
218
|
+
verification_uri_complete: str | None = field(default=None, repr=False)
|
|
219
|
+
scopes: tuple[str, ...] = field(default_factory=tuple)
|
|
220
|
+
expires_in: float = DEFAULT_FLOW_TTL_SECONDS
|
|
221
|
+
polling_interval: float = 5.0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True, slots=True)
|
|
225
|
+
class OAuthFlowStatus:
|
|
226
|
+
flow_id: str
|
|
227
|
+
server_id: str
|
|
228
|
+
kind: OAuthFlowKind
|
|
229
|
+
state: OAuthFlowState
|
|
230
|
+
created_at: float
|
|
231
|
+
updated_at: float
|
|
232
|
+
expires_at: float
|
|
233
|
+
authorization_url: str | None = field(default=None, repr=False)
|
|
234
|
+
verification_uri: str | None = None
|
|
235
|
+
verification_uri_complete: str | None = field(default=None, repr=False)
|
|
236
|
+
user_code: str | None = field(default=None, repr=False)
|
|
237
|
+
polling_interval: float | None = None
|
|
238
|
+
terminal_at: float | None = None
|
|
239
|
+
error_code: str | None = None
|
|
240
|
+
|
|
241
|
+
def to_public_dict(self) -> dict[str, Any]:
|
|
242
|
+
"""Return the allowlisted payload suitable for an IDE protocol response."""
|
|
243
|
+
|
|
244
|
+
payload: dict[str, Any] = {
|
|
245
|
+
"flow_id": self.flow_id,
|
|
246
|
+
"server_id": self.server_id,
|
|
247
|
+
"kind": self.kind.value,
|
|
248
|
+
"state": self.state.value,
|
|
249
|
+
"created_at": self.created_at,
|
|
250
|
+
"updated_at": self.updated_at,
|
|
251
|
+
"expires_at": self.expires_at,
|
|
252
|
+
"terminal_at": self.terminal_at,
|
|
253
|
+
"error_code": self.error_code,
|
|
254
|
+
}
|
|
255
|
+
if self.kind is OAuthFlowKind.AUTHORIZATION_CODE:
|
|
256
|
+
payload["authorization_url"] = self.authorization_url
|
|
257
|
+
else:
|
|
258
|
+
payload.update(
|
|
259
|
+
{
|
|
260
|
+
"verification_uri": self.verification_uri,
|
|
261
|
+
"verification_uri_complete": self.verification_uri_complete,
|
|
262
|
+
"user_code": self.user_code,
|
|
263
|
+
"polling_interval": self.polling_interval,
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
return payload
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass(frozen=True, slots=True)
|
|
270
|
+
class OAuthExchangeMaterial:
|
|
271
|
+
flow_id: str
|
|
272
|
+
server_id: str
|
|
273
|
+
kind: OAuthFlowKind
|
|
274
|
+
token_endpoint: str
|
|
275
|
+
client_id: str = field(repr=False)
|
|
276
|
+
redirect_uri: str | None = None
|
|
277
|
+
pkce_verifier: str | None = field(default=None, repr=False)
|
|
278
|
+
expected_nonce: str | None = field(default=None, repr=False)
|
|
279
|
+
device_code: str | None = field(default=None, repr=False)
|
|
280
|
+
scopes: tuple[str, ...] = field(default_factory=tuple)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass(frozen=True, slots=True)
|
|
284
|
+
class OAuthCompletionClaim:
|
|
285
|
+
flow_id: str
|
|
286
|
+
claim_token: str = field(repr=False)
|
|
287
|
+
material: OAuthExchangeMaterial = field(repr=False)
|
|
288
|
+
lease_expires_at: float
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True, slots=True)
|
|
292
|
+
class OAuthLogoutResult:
|
|
293
|
+
server_id: str
|
|
294
|
+
local_credentials_removed: bool
|
|
295
|
+
active_flows_cancelled: int
|
|
296
|
+
remote_revocation_attempted: bool
|
|
297
|
+
remote_revocation_succeeded: bool | None
|
|
298
|
+
error_code: str | None = None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def default_oauth_flow_registry_path() -> Path:
|
|
302
|
+
override = str(env_get("ALYSIS_DATA_DIR") or "").strip()
|
|
303
|
+
data_dir = Path(override).expanduser() if override else canonical_user_data_dir()
|
|
304
|
+
return data_dir / "ide" / "mcp-oauth-flows.sqlite3"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class McpOAuthFlowRegistry:
|
|
308
|
+
"""SQLite-backed OAuth flow registry with fenced single-use completion."""
|
|
309
|
+
|
|
310
|
+
def __init__(
|
|
311
|
+
self,
|
|
312
|
+
vault: OAuthTokenVault,
|
|
313
|
+
path: str | os.PathLike[str] | None = None,
|
|
314
|
+
*,
|
|
315
|
+
redirect_allowlist: Iterable[str] = (),
|
|
316
|
+
workspace_roots: Iterable[str | os.PathLike[str]] = (),
|
|
317
|
+
clock: Callable[[], float] = time.time,
|
|
318
|
+
id_factory: Callable[[], str] | None = None,
|
|
319
|
+
completion_lease_seconds: float = DEFAULT_COMPLETION_LEASE_SECONDS,
|
|
320
|
+
) -> None:
|
|
321
|
+
if vault is None:
|
|
322
|
+
raise OAuthValidationError("A secure OAuth token vault is required.")
|
|
323
|
+
self.vault = vault
|
|
324
|
+
self.path = Path(path) if path is not None else default_oauth_flow_registry_path()
|
|
325
|
+
self._clock = clock
|
|
326
|
+
self._id_factory = id_factory or (lambda: secrets.token_urlsafe(32))
|
|
327
|
+
self._owner_id = secrets.token_urlsafe(24)
|
|
328
|
+
self._completion_lease_seconds = _bounded_duration(
|
|
329
|
+
completion_lease_seconds,
|
|
330
|
+
field_name="completion lease",
|
|
331
|
+
minimum=1.0,
|
|
332
|
+
maximum=10 * 60.0,
|
|
333
|
+
)
|
|
334
|
+
self._redirect_allowlist = frozenset(
|
|
335
|
+
_validate_redirect_uri(value) for value in redirect_allowlist
|
|
336
|
+
)
|
|
337
|
+
self._lock = threading.RLock()
|
|
338
|
+
self._validate_storage_location(workspace_roots)
|
|
339
|
+
self._prepare_storage()
|
|
340
|
+
self.recover()
|
|
341
|
+
|
|
342
|
+
def start_authorization_code(self, request: AuthorizationCodeFlowRequest) -> OAuthFlowStatus:
|
|
343
|
+
self.recover()
|
|
344
|
+
server_id = _validate_server_id(request.server_id)
|
|
345
|
+
authorization_endpoint = _validate_https_url(
|
|
346
|
+
request.authorization_endpoint, field_name="authorization endpoint"
|
|
347
|
+
)
|
|
348
|
+
token_endpoint = _validate_https_url(request.token_endpoint, field_name="token endpoint")
|
|
349
|
+
redirect_uri = _validate_redirect_uri(request.redirect_uri)
|
|
350
|
+
if not self._redirect_allowlist or redirect_uri not in self._redirect_allowlist:
|
|
351
|
+
raise OAuthValidationError("OAuth redirect URI is not allowlisted.")
|
|
352
|
+
client_id = _validate_client_id(request.client_id)
|
|
353
|
+
scopes = _normalize_scopes(request.scopes)
|
|
354
|
+
expires_in = _bounded_duration(
|
|
355
|
+
request.expires_in,
|
|
356
|
+
field_name="flow expiry",
|
|
357
|
+
minimum=30.0,
|
|
358
|
+
maximum=MAX_FLOW_TTL_SECONDS,
|
|
359
|
+
)
|
|
360
|
+
extras = _normalize_extra_params(request.extra_authorization_params)
|
|
361
|
+
if not isinstance(request.require_nonce, bool):
|
|
362
|
+
raise OAuthValidationError("OAuth nonce policy is invalid.")
|
|
363
|
+
flow_id = self._new_flow_id()
|
|
364
|
+
state = secrets.token_urlsafe(32)
|
|
365
|
+
nonce = secrets.token_urlsafe(32)
|
|
366
|
+
pkce_verifier = secrets.token_urlsafe(64)[:96]
|
|
367
|
+
code_challenge = _pkce_challenge(pkce_verifier)
|
|
368
|
+
authorization_url = _build_authorization_url(
|
|
369
|
+
authorization_endpoint,
|
|
370
|
+
client_id=client_id,
|
|
371
|
+
redirect_uri=redirect_uri,
|
|
372
|
+
scopes=scopes,
|
|
373
|
+
state=state,
|
|
374
|
+
nonce=nonce,
|
|
375
|
+
code_challenge=code_challenge,
|
|
376
|
+
extras=extras,
|
|
377
|
+
)
|
|
378
|
+
now = self._now()
|
|
379
|
+
expires_at = now + expires_in
|
|
380
|
+
row = {
|
|
381
|
+
"flow_id": flow_id,
|
|
382
|
+
"server_id": server_id,
|
|
383
|
+
"kind": OAuthFlowKind.AUTHORIZATION_CODE.value,
|
|
384
|
+
"state": OAuthFlowState.PENDING.value,
|
|
385
|
+
"authorization_url": authorization_url,
|
|
386
|
+
"token_endpoint": token_endpoint,
|
|
387
|
+
"client_id": client_id,
|
|
388
|
+
"redirect_uri": redirect_uri,
|
|
389
|
+
"scopes_json": _encode_scopes(scopes),
|
|
390
|
+
"state_hash": _secret_digest(state),
|
|
391
|
+
"nonce_hash": _secret_digest(nonce),
|
|
392
|
+
"nonce_value": nonce,
|
|
393
|
+
"nonce_required": int(bool(request.require_nonce)),
|
|
394
|
+
"pkce_verifier": pkce_verifier,
|
|
395
|
+
"device_code": None,
|
|
396
|
+
"verification_uri": None,
|
|
397
|
+
"verification_uri_complete": None,
|
|
398
|
+
"user_code": None,
|
|
399
|
+
"polling_interval": None,
|
|
400
|
+
"created_at": now,
|
|
401
|
+
"updated_at": now,
|
|
402
|
+
"expires_at": expires_at,
|
|
403
|
+
}
|
|
404
|
+
self._insert_flow(row)
|
|
405
|
+
return self.status(flow_id)
|
|
406
|
+
|
|
407
|
+
def start_device_code(self, request: DeviceCodeFlowRequest) -> OAuthFlowStatus:
|
|
408
|
+
self.recover()
|
|
409
|
+
server_id = _validate_server_id(request.server_id)
|
|
410
|
+
token_endpoint = _validate_https_url(request.token_endpoint, field_name="token endpoint")
|
|
411
|
+
client_id = _validate_client_id(request.client_id)
|
|
412
|
+
device_code = _required_secret(request.device_code, field_name="device code")
|
|
413
|
+
user_code = _required_public_string(
|
|
414
|
+
request.user_code, field_name="device user code", maximum=256
|
|
415
|
+
)
|
|
416
|
+
verification_uri = _validate_https_url(
|
|
417
|
+
request.verification_uri, field_name="device verification URI"
|
|
418
|
+
)
|
|
419
|
+
verification_uri_complete = None
|
|
420
|
+
if request.verification_uri_complete is not None:
|
|
421
|
+
verification_uri_complete = _validate_https_url(
|
|
422
|
+
request.verification_uri_complete,
|
|
423
|
+
field_name="complete device verification URI",
|
|
424
|
+
)
|
|
425
|
+
if _url_origin(verification_uri_complete) != _url_origin(verification_uri):
|
|
426
|
+
raise OAuthValidationError(
|
|
427
|
+
"Complete device verification URI must use the verification origin."
|
|
428
|
+
)
|
|
429
|
+
scopes = _normalize_scopes(request.scopes)
|
|
430
|
+
expires_in = _bounded_duration(
|
|
431
|
+
request.expires_in,
|
|
432
|
+
field_name="flow expiry",
|
|
433
|
+
minimum=30.0,
|
|
434
|
+
maximum=MAX_FLOW_TTL_SECONDS,
|
|
435
|
+
)
|
|
436
|
+
polling_interval = _bounded_duration(
|
|
437
|
+
request.polling_interval,
|
|
438
|
+
field_name="device polling interval",
|
|
439
|
+
minimum=1.0,
|
|
440
|
+
maximum=60.0,
|
|
441
|
+
)
|
|
442
|
+
flow_id = self._new_flow_id()
|
|
443
|
+
now = self._now()
|
|
444
|
+
row = {
|
|
445
|
+
"flow_id": flow_id,
|
|
446
|
+
"server_id": server_id,
|
|
447
|
+
"kind": OAuthFlowKind.DEVICE_CODE.value,
|
|
448
|
+
"state": OAuthFlowState.PENDING.value,
|
|
449
|
+
"authorization_url": None,
|
|
450
|
+
"token_endpoint": token_endpoint,
|
|
451
|
+
"client_id": client_id,
|
|
452
|
+
"redirect_uri": None,
|
|
453
|
+
"scopes_json": _encode_scopes(scopes),
|
|
454
|
+
"state_hash": None,
|
|
455
|
+
"nonce_hash": None,
|
|
456
|
+
"nonce_value": None,
|
|
457
|
+
"nonce_required": 0,
|
|
458
|
+
"pkce_verifier": None,
|
|
459
|
+
"device_code": device_code,
|
|
460
|
+
"verification_uri": verification_uri,
|
|
461
|
+
"verification_uri_complete": verification_uri_complete,
|
|
462
|
+
"user_code": user_code,
|
|
463
|
+
"polling_interval": polling_interval,
|
|
464
|
+
"created_at": now,
|
|
465
|
+
"updated_at": now,
|
|
466
|
+
"expires_at": now + expires_in,
|
|
467
|
+
}
|
|
468
|
+
self._insert_flow(row)
|
|
469
|
+
return self.status(flow_id)
|
|
470
|
+
|
|
471
|
+
def status(self, flow_id: str) -> OAuthFlowStatus:
|
|
472
|
+
canonical_flow_id = _validate_flow_id(flow_id)
|
|
473
|
+
self.recover(flow_id=canonical_flow_id)
|
|
474
|
+
with self._connect() as connection:
|
|
475
|
+
row = connection.execute(
|
|
476
|
+
"SELECT * FROM oauth_flows WHERE flow_id = ?", (canonical_flow_id,)
|
|
477
|
+
).fetchone()
|
|
478
|
+
if row is None:
|
|
479
|
+
raise OAuthFlowNotFoundError("OAuth flow was not found.")
|
|
480
|
+
return _status_from_row(row)
|
|
481
|
+
|
|
482
|
+
def list(
|
|
483
|
+
self, *, server_id: str | None = None, limit: int = 100
|
|
484
|
+
) -> tuple[OAuthFlowStatus, ...]:
|
|
485
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1 or limit > 500:
|
|
486
|
+
raise OAuthValidationError("OAuth flow list limit is outside the allowed range.")
|
|
487
|
+
canonical_server_id = _validate_server_id(server_id) if server_id is not None else None
|
|
488
|
+
self.recover()
|
|
489
|
+
query = "SELECT * FROM oauth_flows"
|
|
490
|
+
params: tuple[Any, ...]
|
|
491
|
+
if canonical_server_id is None:
|
|
492
|
+
params = (limit,)
|
|
493
|
+
else:
|
|
494
|
+
query += " WHERE server_id = ?"
|
|
495
|
+
params = (canonical_server_id, limit)
|
|
496
|
+
query += " ORDER BY created_at DESC, flow_id DESC LIMIT ?"
|
|
497
|
+
with self._connect() as connection:
|
|
498
|
+
rows = connection.execute(query, params).fetchall()
|
|
499
|
+
return tuple(_status_from_row(row) for row in rows)
|
|
500
|
+
|
|
501
|
+
def begin_completion(
|
|
502
|
+
self,
|
|
503
|
+
flow_id: str,
|
|
504
|
+
*,
|
|
505
|
+
state: str | None = None,
|
|
506
|
+
nonce: str | None = None,
|
|
507
|
+
) -> OAuthCompletionClaim:
|
|
508
|
+
canonical_flow_id = _validate_flow_id(flow_id)
|
|
509
|
+
self.recover(flow_id=canonical_flow_id)
|
|
510
|
+
now = self._now()
|
|
511
|
+
claim_token = secrets.token_urlsafe(32)
|
|
512
|
+
claim_hash = _secret_digest(claim_token)
|
|
513
|
+
with self._transaction() as connection:
|
|
514
|
+
row = connection.execute(
|
|
515
|
+
"SELECT * FROM oauth_flows WHERE flow_id = ?", (canonical_flow_id,)
|
|
516
|
+
).fetchone()
|
|
517
|
+
if row is None:
|
|
518
|
+
raise OAuthFlowNotFoundError("OAuth flow was not found.")
|
|
519
|
+
current = OAuthFlowState(str(row["state"]))
|
|
520
|
+
if current is not OAuthFlowState.PENDING:
|
|
521
|
+
raise OAuthFlowStateError("OAuth flow is not pending completion.")
|
|
522
|
+
if now >= float(row["expires_at"]):
|
|
523
|
+
self._set_terminal(
|
|
524
|
+
connection,
|
|
525
|
+
canonical_flow_id,
|
|
526
|
+
OAuthFlowState.EXPIRED,
|
|
527
|
+
now=now,
|
|
528
|
+
error_code="flow_expired",
|
|
529
|
+
)
|
|
530
|
+
# Preserve the expiry transition even though the transaction
|
|
531
|
+
# context will see the state error raised below.
|
|
532
|
+
connection.commit()
|
|
533
|
+
raise OAuthFlowStateError("OAuth flow has expired.")
|
|
534
|
+
kind = OAuthFlowKind(str(row["kind"]))
|
|
535
|
+
if kind is OAuthFlowKind.AUTHORIZATION_CODE:
|
|
536
|
+
self._validate_callback_proof(row, state=state, nonce=nonce)
|
|
537
|
+
elif state is not None or nonce is not None:
|
|
538
|
+
raise OAuthCompletionRejectedError(
|
|
539
|
+
"Device-code completion does not accept callback proof."
|
|
540
|
+
)
|
|
541
|
+
lease_expires_at = (
|
|
542
|
+
float(row["expires_at"])
|
|
543
|
+
if kind is OAuthFlowKind.DEVICE_CODE
|
|
544
|
+
else min(float(row["expires_at"]), now + self._completion_lease_seconds)
|
|
545
|
+
)
|
|
546
|
+
connection.execute(
|
|
547
|
+
"""
|
|
548
|
+
UPDATE oauth_flows
|
|
549
|
+
SET state = ?, updated_at = ?, completion_owner = ?,
|
|
550
|
+
completion_claim_hash = ?, completion_lease_expires_at = ?,
|
|
551
|
+
error_code = NULL
|
|
552
|
+
WHERE flow_id = ? AND state = ?
|
|
553
|
+
""",
|
|
554
|
+
(
|
|
555
|
+
OAuthFlowState.COMPLETING.value,
|
|
556
|
+
now,
|
|
557
|
+
self._owner_id,
|
|
558
|
+
claim_hash,
|
|
559
|
+
lease_expires_at,
|
|
560
|
+
canonical_flow_id,
|
|
561
|
+
OAuthFlowState.PENDING.value,
|
|
562
|
+
),
|
|
563
|
+
)
|
|
564
|
+
material = _exchange_material_from_row(row)
|
|
565
|
+
return OAuthCompletionClaim(
|
|
566
|
+
flow_id=canonical_flow_id,
|
|
567
|
+
claim_token=claim_token,
|
|
568
|
+
material=material,
|
|
569
|
+
lease_expires_at=lease_expires_at,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def device_poll_material(self, flow_id: str) -> OAuthExchangeMaterial:
|
|
573
|
+
"""Return device polling material without consuming the pending flow.
|
|
574
|
+
|
|
575
|
+
This method is for the trusted backend exchange adapter only. Keeping
|
|
576
|
+
the flow pending while a user authorizes the device means ``cancel``
|
|
577
|
+
and expiry remain effective during a potentially long poll loop.
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
canonical_flow_id = _validate_flow_id(flow_id)
|
|
581
|
+
self.recover(flow_id=canonical_flow_id)
|
|
582
|
+
with self._connect() as connection:
|
|
583
|
+
row = connection.execute(
|
|
584
|
+
"SELECT * FROM oauth_flows WHERE flow_id = ?", (canonical_flow_id,)
|
|
585
|
+
).fetchone()
|
|
586
|
+
if row is None:
|
|
587
|
+
raise OAuthFlowNotFoundError("OAuth flow was not found.")
|
|
588
|
+
if OAuthFlowState(str(row["state"])) is not OAuthFlowState.PENDING:
|
|
589
|
+
raise OAuthFlowStateError("OAuth device flow is not pending authorization.")
|
|
590
|
+
if OAuthFlowKind(str(row["kind"])) is not OAuthFlowKind.DEVICE_CODE:
|
|
591
|
+
raise OAuthFlowStateError("OAuth flow does not use device authorization.")
|
|
592
|
+
return _exchange_material_from_row(row)
|
|
593
|
+
|
|
594
|
+
def finish_completion(
|
|
595
|
+
self,
|
|
596
|
+
claim: OAuthCompletionClaim,
|
|
597
|
+
tokens: OAuthTokenSet,
|
|
598
|
+
*,
|
|
599
|
+
nonce: str | None = None,
|
|
600
|
+
) -> OAuthFlowStatus:
|
|
601
|
+
canonical_flow_id = _validate_flow_id(claim.flow_id)
|
|
602
|
+
if not isinstance(tokens, OAuthTokenSet):
|
|
603
|
+
raise OAuthValidationError("OAuth token result is invalid.")
|
|
604
|
+
row = self._consume_live_claim(canonical_flow_id, claim.claim_token, returned_nonce=nonce)
|
|
605
|
+
server_id = str(row["server_id"])
|
|
606
|
+
try:
|
|
607
|
+
self._vault_store(server_id, tokens, binding_id=canonical_flow_id)
|
|
608
|
+
except OAuthVaultError:
|
|
609
|
+
self._fail_consumed_claim(canonical_flow_id, error_code="vault_store_failed")
|
|
610
|
+
raise
|
|
611
|
+
|
|
612
|
+
now = self._now()
|
|
613
|
+
with self._transaction() as connection:
|
|
614
|
+
live = connection.execute(
|
|
615
|
+
"SELECT state, error_code, completion_owner, completion_claim_hash FROM oauth_flows "
|
|
616
|
+
"WHERE flow_id = ?",
|
|
617
|
+
(canonical_flow_id,),
|
|
618
|
+
).fetchone()
|
|
619
|
+
interrupted = bool(
|
|
620
|
+
live is not None
|
|
621
|
+
and OAuthFlowState(str(live["state"])) is OAuthFlowState.FAILED
|
|
622
|
+
and str(live["error_code"] or "") == "completion_interrupted"
|
|
623
|
+
)
|
|
624
|
+
if not interrupted and (
|
|
625
|
+
live is None
|
|
626
|
+
or OAuthFlowState(str(live["state"])) is not OAuthFlowState.COMPLETING
|
|
627
|
+
or str(live["completion_owner"] or "") != self._owner_id
|
|
628
|
+
or live["completion_claim_hash"] is not None
|
|
629
|
+
):
|
|
630
|
+
# A successful deterministic binding is recoverable, but this
|
|
631
|
+
# claim is still single-use and cannot be finalized twice.
|
|
632
|
+
raise OAuthFlowStateError("OAuth completion claim is no longer active.")
|
|
633
|
+
self._set_terminal(
|
|
634
|
+
connection,
|
|
635
|
+
canonical_flow_id,
|
|
636
|
+
OAuthFlowState.COMPLETED,
|
|
637
|
+
now=now,
|
|
638
|
+
error_code=None,
|
|
639
|
+
)
|
|
640
|
+
return self.status(canonical_flow_id)
|
|
641
|
+
|
|
642
|
+
def complete(
|
|
643
|
+
self,
|
|
644
|
+
flow_id: str,
|
|
645
|
+
tokens: OAuthTokenSet,
|
|
646
|
+
*,
|
|
647
|
+
state: str | None = None,
|
|
648
|
+
nonce: str | None = None,
|
|
649
|
+
) -> OAuthFlowStatus:
|
|
650
|
+
"""Claim and finish a flow when token exchange has already completed."""
|
|
651
|
+
|
|
652
|
+
self._validate_token_nonce_preflight(flow_id, nonce=nonce)
|
|
653
|
+
claim = self.begin_completion(flow_id, state=state, nonce=nonce)
|
|
654
|
+
return self.finish_completion(claim, tokens, nonce=nonce)
|
|
655
|
+
|
|
656
|
+
def fail_completion(
|
|
657
|
+
self, claim: OAuthCompletionClaim, *, error_code: str = "token_exchange_failed"
|
|
658
|
+
) -> OAuthFlowStatus:
|
|
659
|
+
safe_error_code = _validate_error_code(error_code)
|
|
660
|
+
self._fail_live_claim(claim.flow_id, claim.claim_token, error_code=safe_error_code)
|
|
661
|
+
return self.status(claim.flow_id)
|
|
662
|
+
|
|
663
|
+
def fail_pending(
|
|
664
|
+
self, flow_id: str, *, error_code: str = "authorization_failed"
|
|
665
|
+
) -> OAuthFlowStatus:
|
|
666
|
+
"""Fail a pending flow without accepting provider-controlled error text."""
|
|
667
|
+
|
|
668
|
+
canonical_flow_id = _validate_flow_id(flow_id)
|
|
669
|
+
safe_error_code = _validate_error_code(error_code)
|
|
670
|
+
self.recover(flow_id=canonical_flow_id)
|
|
671
|
+
now = self._now()
|
|
672
|
+
with self._transaction() as connection:
|
|
673
|
+
row = connection.execute(
|
|
674
|
+
"SELECT state FROM oauth_flows WHERE flow_id = ?", (canonical_flow_id,)
|
|
675
|
+
).fetchone()
|
|
676
|
+
if row is None:
|
|
677
|
+
raise OAuthFlowNotFoundError("OAuth flow was not found.")
|
|
678
|
+
if OAuthFlowState(str(row["state"])) is not OAuthFlowState.PENDING:
|
|
679
|
+
raise OAuthFlowStateError("OAuth flow is not pending authorization.")
|
|
680
|
+
self._set_terminal(
|
|
681
|
+
connection,
|
|
682
|
+
canonical_flow_id,
|
|
683
|
+
OAuthFlowState.FAILED,
|
|
684
|
+
now=now,
|
|
685
|
+
error_code=safe_error_code,
|
|
686
|
+
)
|
|
687
|
+
return self._status_in_transaction(connection, canonical_flow_id)
|
|
688
|
+
|
|
689
|
+
def cancel(self, flow_id: str) -> OAuthFlowStatus:
|
|
690
|
+
canonical_flow_id = _validate_flow_id(flow_id)
|
|
691
|
+
self.recover(flow_id=canonical_flow_id)
|
|
692
|
+
now = self._now()
|
|
693
|
+
with self._transaction() as connection:
|
|
694
|
+
row = connection.execute(
|
|
695
|
+
"SELECT state FROM oauth_flows WHERE flow_id = ?", (canonical_flow_id,)
|
|
696
|
+
).fetchone()
|
|
697
|
+
if row is None:
|
|
698
|
+
raise OAuthFlowNotFoundError("OAuth flow was not found.")
|
|
699
|
+
current = OAuthFlowState(str(row["state"]))
|
|
700
|
+
if current is OAuthFlowState.CANCELLED:
|
|
701
|
+
return self._status_in_transaction(connection, canonical_flow_id)
|
|
702
|
+
if current is not OAuthFlowState.PENDING:
|
|
703
|
+
raise OAuthFlowStateError("OAuth flow can no longer be cancelled.")
|
|
704
|
+
self._set_terminal(
|
|
705
|
+
connection,
|
|
706
|
+
canonical_flow_id,
|
|
707
|
+
OAuthFlowState.CANCELLED,
|
|
708
|
+
now=now,
|
|
709
|
+
error_code="cancelled_by_user",
|
|
710
|
+
)
|
|
711
|
+
return self._status_in_transaction(connection, canonical_flow_id)
|
|
712
|
+
|
|
713
|
+
def logout(
|
|
714
|
+
self,
|
|
715
|
+
server_id: str,
|
|
716
|
+
*,
|
|
717
|
+
revoker: Callable[[OAuthTokenSet], None] | None = None,
|
|
718
|
+
) -> OAuthLogoutResult:
|
|
719
|
+
canonical_server_id = _validate_server_id(server_id)
|
|
720
|
+
self.recover()
|
|
721
|
+
tokens = self._vault_load(canonical_server_id)
|
|
722
|
+
|
|
723
|
+
revocation_attempted = revoker is not None and tokens is not None
|
|
724
|
+
revocation_succeeded: bool | None = None
|
|
725
|
+
error_code: str | None = None
|
|
726
|
+
if revocation_attempted:
|
|
727
|
+
try:
|
|
728
|
+
assert revoker is not None and tokens is not None
|
|
729
|
+
revoker(tokens)
|
|
730
|
+
revocation_succeeded = True
|
|
731
|
+
except Exception: # noqa: BLE001 - never surface provider/token details
|
|
732
|
+
revocation_succeeded = False
|
|
733
|
+
error_code = "remote_revocation_failed"
|
|
734
|
+
|
|
735
|
+
removed = self._vault_delete(canonical_server_id)
|
|
736
|
+
|
|
737
|
+
now = self._now()
|
|
738
|
+
with self._transaction() as connection:
|
|
739
|
+
active = connection.execute(
|
|
740
|
+
"""
|
|
741
|
+
SELECT COUNT(*) AS count
|
|
742
|
+
FROM oauth_flows
|
|
743
|
+
WHERE server_id = ? AND state IN (?, ?)
|
|
744
|
+
""",
|
|
745
|
+
(
|
|
746
|
+
canonical_server_id,
|
|
747
|
+
OAuthFlowState.PENDING.value,
|
|
748
|
+
OAuthFlowState.COMPLETING.value,
|
|
749
|
+
),
|
|
750
|
+
).fetchone()
|
|
751
|
+
cancelled = int(active["count"] if active is not None else 0)
|
|
752
|
+
connection.execute(
|
|
753
|
+
"""
|
|
754
|
+
UPDATE oauth_flows
|
|
755
|
+
SET state = ?, updated_at = ?, terminal_at = ?, error_code = ?,
|
|
756
|
+
state_hash = NULL, nonce_hash = NULL, nonce_value = NULL,
|
|
757
|
+
pkce_verifier = NULL,
|
|
758
|
+
device_code = NULL, completion_owner = NULL,
|
|
759
|
+
completion_claim_hash = NULL, completion_lease_expires_at = NULL,
|
|
760
|
+
authorization_url = NULL, user_code = NULL,
|
|
761
|
+
verification_uri_complete = NULL
|
|
762
|
+
WHERE server_id = ? AND state IN (?, ?)
|
|
763
|
+
""",
|
|
764
|
+
(
|
|
765
|
+
OAuthFlowState.CANCELLED.value,
|
|
766
|
+
now,
|
|
767
|
+
now,
|
|
768
|
+
"logout",
|
|
769
|
+
canonical_server_id,
|
|
770
|
+
OAuthFlowState.PENDING.value,
|
|
771
|
+
OAuthFlowState.COMPLETING.value,
|
|
772
|
+
),
|
|
773
|
+
)
|
|
774
|
+
return OAuthLogoutResult(
|
|
775
|
+
server_id=canonical_server_id,
|
|
776
|
+
local_credentials_removed=removed,
|
|
777
|
+
active_flows_cancelled=cancelled,
|
|
778
|
+
remote_revocation_attempted=revocation_attempted,
|
|
779
|
+
remote_revocation_succeeded=revocation_succeeded,
|
|
780
|
+
error_code=error_code,
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
def recover(self, *, flow_id: str | None = None) -> int:
|
|
784
|
+
"""Recover interrupted completion and expire overdue flows.
|
|
785
|
+
|
|
786
|
+
A vault binding proves that credentials were atomically stored before a
|
|
787
|
+
crash. Otherwise a completing flow remains leased until its fencing
|
|
788
|
+
deadline and then fails closed rather than retrying a possibly consumed
|
|
789
|
+
authorization code.
|
|
790
|
+
"""
|
|
791
|
+
|
|
792
|
+
canonical_flow_id = _validate_flow_id(flow_id) if flow_id is not None else None
|
|
793
|
+
now = self._now()
|
|
794
|
+
with self._connect() as connection:
|
|
795
|
+
if canonical_flow_id is None:
|
|
796
|
+
rows = connection.execute(
|
|
797
|
+
"SELECT flow_id, server_id, state, expires_at, completion_lease_expires_at "
|
|
798
|
+
"FROM oauth_flows WHERE state IN (?, ?) "
|
|
799
|
+
"OR (state = ? AND error_code = ?)",
|
|
800
|
+
(
|
|
801
|
+
OAuthFlowState.PENDING.value,
|
|
802
|
+
OAuthFlowState.COMPLETING.value,
|
|
803
|
+
OAuthFlowState.FAILED.value,
|
|
804
|
+
"completion_interrupted",
|
|
805
|
+
),
|
|
806
|
+
).fetchall()
|
|
807
|
+
else:
|
|
808
|
+
rows = connection.execute(
|
|
809
|
+
"SELECT flow_id, server_id, state, expires_at, completion_lease_expires_at "
|
|
810
|
+
"FROM oauth_flows WHERE flow_id = ? AND "
|
|
811
|
+
"(state IN (?, ?) OR (state = ? AND error_code = ?))",
|
|
812
|
+
(
|
|
813
|
+
canonical_flow_id,
|
|
814
|
+
OAuthFlowState.PENDING.value,
|
|
815
|
+
OAuthFlowState.COMPLETING.value,
|
|
816
|
+
OAuthFlowState.FAILED.value,
|
|
817
|
+
"completion_interrupted",
|
|
818
|
+
),
|
|
819
|
+
).fetchall()
|
|
820
|
+
|
|
821
|
+
recovered = 0
|
|
822
|
+
for row in rows:
|
|
823
|
+
current = OAuthFlowState(str(row["state"]))
|
|
824
|
+
target: OAuthFlowState | None = None
|
|
825
|
+
error_code: str | None = None
|
|
826
|
+
if current is OAuthFlowState.PENDING and now >= float(row["expires_at"]):
|
|
827
|
+
target = OAuthFlowState.EXPIRED
|
|
828
|
+
error_code = "flow_expired"
|
|
829
|
+
elif current is OAuthFlowState.COMPLETING:
|
|
830
|
+
binding_exists = self._vault_has_binding(
|
|
831
|
+
str(row["server_id"]), binding_id=str(row["flow_id"])
|
|
832
|
+
)
|
|
833
|
+
if binding_exists:
|
|
834
|
+
target = OAuthFlowState.COMPLETED
|
|
835
|
+
else:
|
|
836
|
+
lease_expiry = float(row["completion_lease_expires_at"] or 0.0)
|
|
837
|
+
if now >= min(float(row["expires_at"]), lease_expiry):
|
|
838
|
+
target = OAuthFlowState.FAILED
|
|
839
|
+
error_code = "completion_interrupted"
|
|
840
|
+
elif current is OAuthFlowState.FAILED:
|
|
841
|
+
binding_exists = self._vault_has_binding(
|
|
842
|
+
str(row["server_id"]), binding_id=str(row["flow_id"])
|
|
843
|
+
)
|
|
844
|
+
if binding_exists:
|
|
845
|
+
target = OAuthFlowState.COMPLETED
|
|
846
|
+
error_code = None
|
|
847
|
+
if target is None:
|
|
848
|
+
continue
|
|
849
|
+
with self._transaction() as connection:
|
|
850
|
+
live = connection.execute(
|
|
851
|
+
"SELECT state FROM oauth_flows WHERE flow_id = ?", (str(row["flow_id"]),)
|
|
852
|
+
).fetchone()
|
|
853
|
+
if live is None or OAuthFlowState(str(live["state"])) is not current:
|
|
854
|
+
continue
|
|
855
|
+
self._set_terminal(
|
|
856
|
+
connection,
|
|
857
|
+
str(row["flow_id"]),
|
|
858
|
+
target,
|
|
859
|
+
now=now,
|
|
860
|
+
error_code=error_code,
|
|
861
|
+
)
|
|
862
|
+
recovered += 1
|
|
863
|
+
return recovered
|
|
864
|
+
|
|
865
|
+
def cleanup(
|
|
866
|
+
self, *, terminal_retention_seconds: float = DEFAULT_TERMINAL_RETENTION_SECONDS
|
|
867
|
+
) -> int:
|
|
868
|
+
retention = _bounded_duration(
|
|
869
|
+
terminal_retention_seconds,
|
|
870
|
+
field_name="terminal retention",
|
|
871
|
+
minimum=0.0,
|
|
872
|
+
maximum=365 * 24 * 60 * 60.0,
|
|
873
|
+
)
|
|
874
|
+
self.recover()
|
|
875
|
+
cutoff = self._now() - retention
|
|
876
|
+
with self._transaction() as connection:
|
|
877
|
+
cursor = connection.execute(
|
|
878
|
+
"""
|
|
879
|
+
DELETE FROM oauth_flows
|
|
880
|
+
WHERE state IN (?, ?, ?, ?) AND terminal_at IS NOT NULL AND terminal_at <= ?
|
|
881
|
+
""",
|
|
882
|
+
(
|
|
883
|
+
OAuthFlowState.COMPLETED.value,
|
|
884
|
+
OAuthFlowState.CANCELLED.value,
|
|
885
|
+
OAuthFlowState.FAILED.value,
|
|
886
|
+
OAuthFlowState.EXPIRED.value,
|
|
887
|
+
cutoff,
|
|
888
|
+
),
|
|
889
|
+
)
|
|
890
|
+
return max(0, int(cursor.rowcount))
|
|
891
|
+
|
|
892
|
+
def _prepare_storage(self) -> None:
|
|
893
|
+
try:
|
|
894
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
895
|
+
_restrict_permissions(self.path.parent, directory=True)
|
|
896
|
+
_secure_precreate_database(self.path)
|
|
897
|
+
with self._connect() as connection:
|
|
898
|
+
connection.executescript(
|
|
899
|
+
"""
|
|
900
|
+
CREATE TABLE IF NOT EXISTS schema_info (
|
|
901
|
+
version INTEGER NOT NULL
|
|
902
|
+
);
|
|
903
|
+
CREATE TABLE IF NOT EXISTS oauth_flows (
|
|
904
|
+
flow_id TEXT PRIMARY KEY,
|
|
905
|
+
server_id TEXT NOT NULL,
|
|
906
|
+
kind TEXT NOT NULL,
|
|
907
|
+
state TEXT NOT NULL,
|
|
908
|
+
authorization_url TEXT,
|
|
909
|
+
token_endpoint TEXT NOT NULL,
|
|
910
|
+
client_id TEXT NOT NULL,
|
|
911
|
+
redirect_uri TEXT,
|
|
912
|
+
scopes_json TEXT NOT NULL,
|
|
913
|
+
state_hash TEXT,
|
|
914
|
+
nonce_hash TEXT,
|
|
915
|
+
nonce_value TEXT,
|
|
916
|
+
nonce_required INTEGER NOT NULL DEFAULT 0,
|
|
917
|
+
pkce_verifier TEXT,
|
|
918
|
+
device_code TEXT,
|
|
919
|
+
verification_uri TEXT,
|
|
920
|
+
verification_uri_complete TEXT,
|
|
921
|
+
user_code TEXT,
|
|
922
|
+
polling_interval REAL,
|
|
923
|
+
created_at REAL NOT NULL,
|
|
924
|
+
updated_at REAL NOT NULL,
|
|
925
|
+
expires_at REAL NOT NULL,
|
|
926
|
+
terminal_at REAL,
|
|
927
|
+
error_code TEXT,
|
|
928
|
+
completion_owner TEXT,
|
|
929
|
+
completion_claim_hash TEXT,
|
|
930
|
+
completion_lease_expires_at REAL
|
|
931
|
+
);
|
|
932
|
+
CREATE UNIQUE INDEX IF NOT EXISTS oauth_flows_one_active_server
|
|
933
|
+
ON oauth_flows(server_id)
|
|
934
|
+
WHERE state IN ('pending', 'completing');
|
|
935
|
+
CREATE INDEX IF NOT EXISTS oauth_flows_terminal_cleanup
|
|
936
|
+
ON oauth_flows(state, terminal_at);
|
|
937
|
+
"""
|
|
938
|
+
)
|
|
939
|
+
row = connection.execute("SELECT version FROM schema_info LIMIT 1").fetchone()
|
|
940
|
+
if row is None:
|
|
941
|
+
connection.execute(
|
|
942
|
+
"INSERT INTO schema_info(version) VALUES (?)", (SCHEMA_VERSION,)
|
|
943
|
+
)
|
|
944
|
+
elif int(row["version"]) != SCHEMA_VERSION:
|
|
945
|
+
raise OAuthLifecycleError("OAuth flow registry schema is unsupported.")
|
|
946
|
+
_restrict_permissions(self.path, directory=False)
|
|
947
|
+
except OAuthLifecycleError:
|
|
948
|
+
raise
|
|
949
|
+
except Exception as exc: # noqa: BLE001
|
|
950
|
+
raise OAuthLifecycleError("OAuth flow registry could not be initialized.") from exc
|
|
951
|
+
|
|
952
|
+
def _vault_store(self, server_id: str, tokens: OAuthTokenSet, *, binding_id: str) -> None:
|
|
953
|
+
failed = False
|
|
954
|
+
try:
|
|
955
|
+
self.vault.store(server_id, tokens, binding_id=binding_id)
|
|
956
|
+
except Exception: # noqa: BLE001 - erase secret-bearing exception context
|
|
957
|
+
failed = True
|
|
958
|
+
if failed:
|
|
959
|
+
raise OAuthVaultError("OAuth credentials could not be stored securely.")
|
|
960
|
+
|
|
961
|
+
def _vault_load(self, server_id: str) -> OAuthTokenSet | None:
|
|
962
|
+
failed = False
|
|
963
|
+
tokens: OAuthTokenSet | None = None
|
|
964
|
+
try:
|
|
965
|
+
tokens = self.vault.load(server_id)
|
|
966
|
+
except Exception: # noqa: BLE001 - erase secret-bearing exception context
|
|
967
|
+
failed = True
|
|
968
|
+
if failed:
|
|
969
|
+
raise OAuthVaultError("OAuth credentials could not be read securely.")
|
|
970
|
+
return tokens
|
|
971
|
+
|
|
972
|
+
def _vault_has_binding(self, server_id: str, *, binding_id: str) -> bool:
|
|
973
|
+
failed = False
|
|
974
|
+
result = False
|
|
975
|
+
try:
|
|
976
|
+
result = bool(self.vault.has_binding(server_id, binding_id=binding_id))
|
|
977
|
+
except Exception: # noqa: BLE001 - erase secret-bearing exception context
|
|
978
|
+
failed = True
|
|
979
|
+
if failed:
|
|
980
|
+
raise OAuthVaultError("OAuth credential recovery check failed.")
|
|
981
|
+
return result
|
|
982
|
+
|
|
983
|
+
def _vault_delete(self, server_id: str) -> bool:
|
|
984
|
+
failed = False
|
|
985
|
+
removed = False
|
|
986
|
+
try:
|
|
987
|
+
removed = bool(self.vault.delete(server_id))
|
|
988
|
+
except Exception: # noqa: BLE001 - erase secret-bearing exception context
|
|
989
|
+
failed = True
|
|
990
|
+
if failed:
|
|
991
|
+
raise OAuthVaultError("OAuth credentials could not be removed securely.")
|
|
992
|
+
return removed
|
|
993
|
+
|
|
994
|
+
def _insert_flow(self, row: Mapping[str, Any]) -> None:
|
|
995
|
+
columns = tuple(row)
|
|
996
|
+
placeholders = ", ".join("?" for _ in columns)
|
|
997
|
+
try:
|
|
998
|
+
with self._transaction() as connection:
|
|
999
|
+
connection.execute(
|
|
1000
|
+
f"INSERT INTO oauth_flows ({', '.join(columns)}) VALUES ({placeholders})", # noqa: S608
|
|
1001
|
+
tuple(row[column] for column in columns),
|
|
1002
|
+
)
|
|
1003
|
+
except sqlite3.IntegrityError as exc:
|
|
1004
|
+
raise OAuthFlowConflictError(
|
|
1005
|
+
"An OAuth flow is already active for this server."
|
|
1006
|
+
) from exc
|
|
1007
|
+
except OAuthLifecycleError:
|
|
1008
|
+
raise
|
|
1009
|
+
except Exception as exc: # noqa: BLE001
|
|
1010
|
+
raise OAuthLifecycleError("OAuth flow could not be persisted.") from exc
|
|
1011
|
+
|
|
1012
|
+
def _consume_live_claim(
|
|
1013
|
+
self, flow_id: str, claim_token: str, *, returned_nonce: str | None
|
|
1014
|
+
) -> sqlite3.Row:
|
|
1015
|
+
now = self._now()
|
|
1016
|
+
with self._transaction() as connection:
|
|
1017
|
+
row = self._select_claim_for_update(connection, flow_id, claim_token, now=now)
|
|
1018
|
+
if row is None:
|
|
1019
|
+
raise OAuthFlowStateError("OAuth completion claim is no longer active.")
|
|
1020
|
+
kind = OAuthFlowKind(str(row["kind"]))
|
|
1021
|
+
if kind is OAuthFlowKind.AUTHORIZATION_CODE:
|
|
1022
|
+
nonce_required = bool(int(row["nonce_required"] or 0))
|
|
1023
|
+
expected_nonce_hash = str(row["nonce_hash"] or "")
|
|
1024
|
+
if returned_nonce is None:
|
|
1025
|
+
if nonce_required:
|
|
1026
|
+
raise OAuthCompletionRejectedError("OAuth token nonce proof is incomplete.")
|
|
1027
|
+
elif not hmac.compare_digest(expected_nonce_hash, _secret_digest(returned_nonce)):
|
|
1028
|
+
raise OAuthCompletionRejectedError("OAuth token nonce proof was rejected.")
|
|
1029
|
+
elif returned_nonce is not None:
|
|
1030
|
+
raise OAuthCompletionRejectedError(
|
|
1031
|
+
"Device-code completion does not accept a token nonce."
|
|
1032
|
+
)
|
|
1033
|
+
connection.execute(
|
|
1034
|
+
"UPDATE oauth_flows SET completion_claim_hash = NULL, updated_at = ? "
|
|
1035
|
+
"WHERE flow_id = ?",
|
|
1036
|
+
(now, flow_id),
|
|
1037
|
+
)
|
|
1038
|
+
return row
|
|
1039
|
+
|
|
1040
|
+
def _select_claim_for_update(
|
|
1041
|
+
self,
|
|
1042
|
+
connection: sqlite3.Connection,
|
|
1043
|
+
flow_id: str,
|
|
1044
|
+
claim_token: str,
|
|
1045
|
+
*,
|
|
1046
|
+
now: float,
|
|
1047
|
+
) -> sqlite3.Row | None:
|
|
1048
|
+
if not isinstance(claim_token, str) or not claim_token:
|
|
1049
|
+
return None
|
|
1050
|
+
row = connection.execute(
|
|
1051
|
+
"SELECT * FROM oauth_flows WHERE flow_id = ?", (flow_id,)
|
|
1052
|
+
).fetchone()
|
|
1053
|
+
if row is None or OAuthFlowState(str(row["state"])) is not OAuthFlowState.COMPLETING:
|
|
1054
|
+
return None
|
|
1055
|
+
if str(row["completion_owner"] or "") != self._owner_id:
|
|
1056
|
+
return None
|
|
1057
|
+
expected = str(row["completion_claim_hash"] or "")
|
|
1058
|
+
if not hmac.compare_digest(expected, _secret_digest(claim_token)):
|
|
1059
|
+
return None
|
|
1060
|
+
if now >= float(row["completion_lease_expires_at"] or 0.0):
|
|
1061
|
+
return None
|
|
1062
|
+
return row
|
|
1063
|
+
|
|
1064
|
+
def _fail_live_claim(self, flow_id: str, claim_token: str, *, error_code: str) -> None:
|
|
1065
|
+
canonical_flow_id = _validate_flow_id(flow_id)
|
|
1066
|
+
now = self._now()
|
|
1067
|
+
with self._transaction() as connection:
|
|
1068
|
+
row = self._select_claim_for_update(connection, canonical_flow_id, claim_token, now=now)
|
|
1069
|
+
if row is None:
|
|
1070
|
+
raise OAuthFlowStateError("OAuth completion claim is no longer active.")
|
|
1071
|
+
self._set_terminal(
|
|
1072
|
+
connection,
|
|
1073
|
+
canonical_flow_id,
|
|
1074
|
+
OAuthFlowState.FAILED,
|
|
1075
|
+
now=now,
|
|
1076
|
+
error_code=error_code,
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
def _fail_consumed_claim(self, flow_id: str, *, error_code: str) -> None:
|
|
1080
|
+
now = self._now()
|
|
1081
|
+
with self._transaction() as connection:
|
|
1082
|
+
row = connection.execute(
|
|
1083
|
+
"SELECT state, completion_owner, completion_claim_hash FROM oauth_flows "
|
|
1084
|
+
"WHERE flow_id = ?",
|
|
1085
|
+
(flow_id,),
|
|
1086
|
+
).fetchone()
|
|
1087
|
+
if (
|
|
1088
|
+
row is None
|
|
1089
|
+
or OAuthFlowState(str(row["state"])) is not OAuthFlowState.COMPLETING
|
|
1090
|
+
or str(row["completion_owner"] or "") != self._owner_id
|
|
1091
|
+
or row["completion_claim_hash"] is not None
|
|
1092
|
+
):
|
|
1093
|
+
raise OAuthFlowStateError("OAuth completion claim is no longer active.")
|
|
1094
|
+
self._set_terminal(
|
|
1095
|
+
connection,
|
|
1096
|
+
flow_id,
|
|
1097
|
+
OAuthFlowState.FAILED,
|
|
1098
|
+
now=now,
|
|
1099
|
+
error_code=error_code,
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
def _validate_callback_proof(
|
|
1103
|
+
self, row: sqlite3.Row, *, state: str | None, nonce: str | None
|
|
1104
|
+
) -> None:
|
|
1105
|
+
if not isinstance(state, str):
|
|
1106
|
+
raise OAuthCompletionRejectedError("OAuth callback proof is incomplete.")
|
|
1107
|
+
expected_state = str(row["state_hash"] or "")
|
|
1108
|
+
expected_nonce = str(row["nonce_hash"] or "")
|
|
1109
|
+
state_valid = hmac.compare_digest(expected_state, _secret_digest(state))
|
|
1110
|
+
nonce_valid = nonce is None or hmac.compare_digest(expected_nonce, _secret_digest(nonce))
|
|
1111
|
+
if not state_valid or not nonce_valid:
|
|
1112
|
+
raise OAuthCompletionRejectedError("OAuth callback proof was rejected.")
|
|
1113
|
+
|
|
1114
|
+
def _validate_token_nonce_preflight(self, flow_id: str, *, nonce: str | None) -> None:
|
|
1115
|
+
canonical_flow_id = _validate_flow_id(flow_id)
|
|
1116
|
+
with self._connect() as connection:
|
|
1117
|
+
row = connection.execute(
|
|
1118
|
+
"SELECT kind, nonce_hash, nonce_required FROM oauth_flows WHERE flow_id = ?",
|
|
1119
|
+
(canonical_flow_id,),
|
|
1120
|
+
).fetchone()
|
|
1121
|
+
if row is None:
|
|
1122
|
+
raise OAuthFlowNotFoundError("OAuth flow was not found.")
|
|
1123
|
+
if OAuthFlowKind(str(row["kind"])) is OAuthFlowKind.DEVICE_CODE:
|
|
1124
|
+
if nonce is not None:
|
|
1125
|
+
raise OAuthCompletionRejectedError(
|
|
1126
|
+
"Device-code completion does not accept a token nonce."
|
|
1127
|
+
)
|
|
1128
|
+
return
|
|
1129
|
+
nonce_required = bool(int(row["nonce_required"] or 0))
|
|
1130
|
+
if nonce is None:
|
|
1131
|
+
if nonce_required:
|
|
1132
|
+
raise OAuthCompletionRejectedError("OAuth token nonce proof is incomplete.")
|
|
1133
|
+
return
|
|
1134
|
+
if not hmac.compare_digest(str(row["nonce_hash"] or ""), _secret_digest(nonce)):
|
|
1135
|
+
raise OAuthCompletionRejectedError("OAuth token nonce proof was rejected.")
|
|
1136
|
+
|
|
1137
|
+
def _set_terminal(
|
|
1138
|
+
self,
|
|
1139
|
+
connection: sqlite3.Connection,
|
|
1140
|
+
flow_id: str,
|
|
1141
|
+
state: OAuthFlowState,
|
|
1142
|
+
*,
|
|
1143
|
+
now: float,
|
|
1144
|
+
error_code: str | None,
|
|
1145
|
+
) -> None:
|
|
1146
|
+
if state not in TERMINAL_STATES:
|
|
1147
|
+
raise OAuthLifecycleError("Internal OAuth terminal transition is invalid.")
|
|
1148
|
+
connection.execute(
|
|
1149
|
+
"""
|
|
1150
|
+
UPDATE oauth_flows
|
|
1151
|
+
SET state = ?, updated_at = ?, terminal_at = ?, error_code = ?,
|
|
1152
|
+
state_hash = NULL, nonce_hash = NULL, nonce_value = NULL,
|
|
1153
|
+
pkce_verifier = NULL,
|
|
1154
|
+
device_code = NULL, completion_owner = NULL,
|
|
1155
|
+
completion_claim_hash = NULL, completion_lease_expires_at = NULL,
|
|
1156
|
+
authorization_url = NULL, user_code = NULL,
|
|
1157
|
+
verification_uri_complete = NULL
|
|
1158
|
+
WHERE flow_id = ?
|
|
1159
|
+
""",
|
|
1160
|
+
(state.value, now, now, error_code, flow_id),
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
def _status_in_transaction(
|
|
1164
|
+
self, connection: sqlite3.Connection, flow_id: str
|
|
1165
|
+
) -> OAuthFlowStatus:
|
|
1166
|
+
row = connection.execute(
|
|
1167
|
+
"SELECT * FROM oauth_flows WHERE flow_id = ?", (flow_id,)
|
|
1168
|
+
).fetchone()
|
|
1169
|
+
if row is None:
|
|
1170
|
+
raise OAuthFlowNotFoundError("OAuth flow was not found.")
|
|
1171
|
+
return _status_from_row(row)
|
|
1172
|
+
|
|
1173
|
+
def _new_flow_id(self) -> str:
|
|
1174
|
+
for _ in range(8):
|
|
1175
|
+
candidate = str(self._id_factory())
|
|
1176
|
+
if _FLOW_ID_RE.fullmatch(candidate):
|
|
1177
|
+
return candidate
|
|
1178
|
+
raise OAuthLifecycleError("Could not allocate an opaque OAuth flow id.")
|
|
1179
|
+
|
|
1180
|
+
def _now(self) -> float:
|
|
1181
|
+
value = self._clock()
|
|
1182
|
+
if (
|
|
1183
|
+
isinstance(value, bool)
|
|
1184
|
+
or not isinstance(value, (int, float))
|
|
1185
|
+
or not math.isfinite(value)
|
|
1186
|
+
):
|
|
1187
|
+
raise OAuthLifecycleError("OAuth lifecycle clock returned an invalid value.")
|
|
1188
|
+
return float(value)
|
|
1189
|
+
|
|
1190
|
+
def _validate_storage_location(self, workspace_roots: Iterable[str | os.PathLike[str]]) -> None:
|
|
1191
|
+
candidate = self.path.expanduser().resolve(strict=False)
|
|
1192
|
+
for raw_root in workspace_roots:
|
|
1193
|
+
root = Path(raw_root).expanduser().resolve(strict=False)
|
|
1194
|
+
if candidate == root or root in candidate.parents:
|
|
1195
|
+
raise OAuthValidationError("OAuth flow registry must be outside the workspace.")
|
|
1196
|
+
|
|
1197
|
+
@contextmanager
|
|
1198
|
+
def _connect(self) -> Any:
|
|
1199
|
+
connection = sqlite3.connect(
|
|
1200
|
+
self.path,
|
|
1201
|
+
timeout=10.0,
|
|
1202
|
+
isolation_level=None,
|
|
1203
|
+
check_same_thread=False,
|
|
1204
|
+
)
|
|
1205
|
+
connection.row_factory = sqlite3.Row
|
|
1206
|
+
try:
|
|
1207
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
1208
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
1209
|
+
connection.execute("PRAGMA synchronous = FULL")
|
|
1210
|
+
connection.execute("PRAGMA busy_timeout = 10000")
|
|
1211
|
+
yield connection
|
|
1212
|
+
finally:
|
|
1213
|
+
connection.close()
|
|
1214
|
+
|
|
1215
|
+
@contextmanager
|
|
1216
|
+
def _transaction(self) -> Any:
|
|
1217
|
+
with self._lock, self._connect() as connection:
|
|
1218
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
1219
|
+
try:
|
|
1220
|
+
yield connection
|
|
1221
|
+
except BaseException:
|
|
1222
|
+
connection.rollback()
|
|
1223
|
+
raise
|
|
1224
|
+
else:
|
|
1225
|
+
connection.commit()
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _status_from_row(row: sqlite3.Row) -> OAuthFlowStatus:
|
|
1229
|
+
return OAuthFlowStatus(
|
|
1230
|
+
flow_id=str(row["flow_id"]),
|
|
1231
|
+
server_id=str(row["server_id"]),
|
|
1232
|
+
kind=OAuthFlowKind(str(row["kind"])),
|
|
1233
|
+
state=OAuthFlowState(str(row["state"])),
|
|
1234
|
+
created_at=float(row["created_at"]),
|
|
1235
|
+
updated_at=float(row["updated_at"]),
|
|
1236
|
+
expires_at=float(row["expires_at"]),
|
|
1237
|
+
authorization_url=_optional_row_string(row, "authorization_url"),
|
|
1238
|
+
verification_uri=_optional_row_string(row, "verification_uri"),
|
|
1239
|
+
verification_uri_complete=_optional_row_string(row, "verification_uri_complete"),
|
|
1240
|
+
user_code=_optional_row_string(row, "user_code"),
|
|
1241
|
+
polling_interval=(
|
|
1242
|
+
float(row["polling_interval"]) if row["polling_interval"] is not None else None
|
|
1243
|
+
),
|
|
1244
|
+
terminal_at=float(row["terminal_at"]) if row["terminal_at"] is not None else None,
|
|
1245
|
+
error_code=_optional_row_string(row, "error_code"),
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def _exchange_material_from_row(row: sqlite3.Row) -> OAuthExchangeMaterial:
|
|
1250
|
+
return OAuthExchangeMaterial(
|
|
1251
|
+
flow_id=str(row["flow_id"]),
|
|
1252
|
+
server_id=str(row["server_id"]),
|
|
1253
|
+
kind=OAuthFlowKind(str(row["kind"])),
|
|
1254
|
+
token_endpoint=str(row["token_endpoint"]),
|
|
1255
|
+
client_id=str(row["client_id"]),
|
|
1256
|
+
redirect_uri=_optional_row_string(row, "redirect_uri"),
|
|
1257
|
+
pkce_verifier=_optional_row_string(row, "pkce_verifier"),
|
|
1258
|
+
expected_nonce=_optional_row_string(row, "nonce_value"),
|
|
1259
|
+
device_code=_optional_row_string(row, "device_code"),
|
|
1260
|
+
scopes=_decode_scopes(str(row["scopes_json"])),
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _optional_row_string(row: sqlite3.Row, key: str) -> str | None:
|
|
1265
|
+
value = row[key]
|
|
1266
|
+
return str(value) if value is not None else None
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _validate_server_id(value: object) -> str:
|
|
1270
|
+
if not isinstance(value, str) or not _SERVER_ID_RE.fullmatch(value.strip()):
|
|
1271
|
+
raise OAuthValidationError("OAuth server id is invalid.")
|
|
1272
|
+
return value.strip()
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _validate_flow_id(value: object) -> str:
|
|
1276
|
+
if not isinstance(value, str) or not _FLOW_ID_RE.fullmatch(value):
|
|
1277
|
+
raise OAuthValidationError("OAuth flow id is invalid.")
|
|
1278
|
+
return value
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _validate_client_id(value: object) -> str:
|
|
1282
|
+
return _required_public_string(value, field_name="OAuth client id", maximum=1024)
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _required_secret(value: object, *, field_name: str) -> str:
|
|
1286
|
+
if not isinstance(value, str) or not value or len(value.encode("utf-8")) > 8192:
|
|
1287
|
+
raise OAuthValidationError(f"OAuth {field_name} is invalid.")
|
|
1288
|
+
return value
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def _required_public_string(value: object, *, field_name: str, maximum: int) -> str:
|
|
1292
|
+
if not isinstance(value, str):
|
|
1293
|
+
raise OAuthValidationError(f"{field_name} is invalid.")
|
|
1294
|
+
cleaned = value.strip()
|
|
1295
|
+
if not cleaned or len(cleaned) > maximum or any(ord(char) < 0x20 for char in cleaned):
|
|
1296
|
+
raise OAuthValidationError(f"{field_name} is invalid.")
|
|
1297
|
+
return cleaned
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _normalize_scopes(values: Iterable[str]) -> tuple[str, ...]:
|
|
1301
|
+
if isinstance(values, (str, bytes)):
|
|
1302
|
+
raise OAuthValidationError("OAuth scopes must be a collection.")
|
|
1303
|
+
try:
|
|
1304
|
+
raw_values = tuple(values)
|
|
1305
|
+
except TypeError as exc:
|
|
1306
|
+
raise OAuthValidationError("OAuth scopes must be a collection.") from exc
|
|
1307
|
+
if len(raw_values) > MAX_SCOPES:
|
|
1308
|
+
raise OAuthValidationError("OAuth scope count exceeds the allowed limit.")
|
|
1309
|
+
normalized: list[str] = []
|
|
1310
|
+
seen: set[str] = set()
|
|
1311
|
+
for value in raw_values:
|
|
1312
|
+
if not isinstance(value, str) or not _SCOPE_RE.fullmatch(value):
|
|
1313
|
+
raise OAuthValidationError("OAuth scope is invalid.")
|
|
1314
|
+
if value not in seen:
|
|
1315
|
+
seen.add(value)
|
|
1316
|
+
normalized.append(value)
|
|
1317
|
+
return tuple(normalized)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def _normalize_extra_params(values: Mapping[str, JsonScalar]) -> dict[str, str]:
|
|
1321
|
+
if not isinstance(values, Mapping):
|
|
1322
|
+
raise OAuthValidationError("OAuth authorization parameters must be an object.")
|
|
1323
|
+
if len(values) > 32:
|
|
1324
|
+
raise OAuthValidationError("OAuth authorization parameter count exceeds the limit.")
|
|
1325
|
+
normalized: dict[str, str] = {}
|
|
1326
|
+
for raw_name, raw_value in values.items():
|
|
1327
|
+
if not isinstance(raw_name, str) or not _PARAM_NAME_RE.fullmatch(raw_name):
|
|
1328
|
+
raise OAuthValidationError("OAuth authorization parameter name is invalid.")
|
|
1329
|
+
name = raw_name.lower()
|
|
1330
|
+
if name in _RESERVED_AUTH_PARAMS:
|
|
1331
|
+
raise OAuthValidationError("OAuth authorization parameter overrides a reserved field.")
|
|
1332
|
+
if name in _SENSITIVE_URL_PARAM_NAMES:
|
|
1333
|
+
raise OAuthValidationError("OAuth authorization parameter may expose credentials.")
|
|
1334
|
+
if raw_value is None:
|
|
1335
|
+
continue
|
|
1336
|
+
if isinstance(raw_value, bool):
|
|
1337
|
+
value = "true" if raw_value else "false"
|
|
1338
|
+
elif isinstance(raw_value, (str, int, float)):
|
|
1339
|
+
if isinstance(raw_value, float) and not math.isfinite(raw_value):
|
|
1340
|
+
raise OAuthValidationError("OAuth authorization parameter value is invalid.")
|
|
1341
|
+
value = str(raw_value)
|
|
1342
|
+
else:
|
|
1343
|
+
raise OAuthValidationError("OAuth authorization parameter value is invalid.")
|
|
1344
|
+
if len(value) > 1024 or any(ord(char) < 0x20 for char in value):
|
|
1345
|
+
raise OAuthValidationError("OAuth authorization parameter value is invalid.")
|
|
1346
|
+
normalized[raw_name] = value
|
|
1347
|
+
return normalized
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def _validate_https_url(value: object, *, field_name: str) -> str:
|
|
1351
|
+
if not isinstance(value, str) or not value or len(value) > MAX_URL_LENGTH:
|
|
1352
|
+
raise OAuthValidationError(f"OAuth {field_name} is invalid.")
|
|
1353
|
+
try:
|
|
1354
|
+
split = urlsplit(value)
|
|
1355
|
+
port = split.port
|
|
1356
|
+
except ValueError as exc:
|
|
1357
|
+
raise OAuthValidationError(f"OAuth {field_name} is invalid.") from exc
|
|
1358
|
+
if (
|
|
1359
|
+
split.scheme.lower() != "https"
|
|
1360
|
+
or not split.hostname
|
|
1361
|
+
or split.username is not None
|
|
1362
|
+
or split.password is not None
|
|
1363
|
+
or split.fragment
|
|
1364
|
+
or port is not None
|
|
1365
|
+
and (port < 1 or port > 65535)
|
|
1366
|
+
):
|
|
1367
|
+
raise OAuthValidationError(f"OAuth {field_name} must be a secure HTTPS URL.")
|
|
1368
|
+
if any(
|
|
1369
|
+
name.lower() in _SENSITIVE_URL_PARAM_NAMES
|
|
1370
|
+
for name, _ in parse_qsl(split.query, keep_blank_values=True)
|
|
1371
|
+
):
|
|
1372
|
+
raise OAuthValidationError(f"OAuth {field_name} may expose credentials.")
|
|
1373
|
+
return urlunsplit(("https", split.netloc.lower(), split.path or "/", split.query, ""))
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def _validate_redirect_uri(value: object) -> str:
|
|
1377
|
+
if not isinstance(value, str) or not value or len(value) > MAX_URL_LENGTH:
|
|
1378
|
+
raise OAuthValidationError("OAuth redirect URI is invalid.")
|
|
1379
|
+
try:
|
|
1380
|
+
split = urlsplit(value)
|
|
1381
|
+
port = split.port
|
|
1382
|
+
except ValueError as exc:
|
|
1383
|
+
raise OAuthValidationError("OAuth redirect URI is invalid.") from exc
|
|
1384
|
+
host = str(split.hostname or "").lower()
|
|
1385
|
+
if (
|
|
1386
|
+
split.scheme.lower() != "http"
|
|
1387
|
+
or host not in {"127.0.0.1", "::1"}
|
|
1388
|
+
or split.username is not None
|
|
1389
|
+
or split.password is not None
|
|
1390
|
+
or split.fragment
|
|
1391
|
+
or split.query
|
|
1392
|
+
or port is None
|
|
1393
|
+
or port < 1024
|
|
1394
|
+
or port > 65535
|
|
1395
|
+
or not split.path.startswith("/")
|
|
1396
|
+
):
|
|
1397
|
+
raise OAuthValidationError("OAuth redirect URI must be an allowlisted loopback URL.")
|
|
1398
|
+
normalized_host = f"[{host}]" if ":" in host else host
|
|
1399
|
+
return urlunsplit(("http", f"{normalized_host}:{port}", split.path or "/", "", ""))
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def _url_origin(value: str) -> tuple[str, str, int | None]:
|
|
1403
|
+
split = urlsplit(value)
|
|
1404
|
+
return split.scheme.lower(), str(split.hostname or "").lower(), split.port
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
def _build_authorization_url(
|
|
1408
|
+
endpoint: str,
|
|
1409
|
+
*,
|
|
1410
|
+
client_id: str,
|
|
1411
|
+
redirect_uri: str,
|
|
1412
|
+
scopes: tuple[str, ...],
|
|
1413
|
+
state: str,
|
|
1414
|
+
nonce: str,
|
|
1415
|
+
code_challenge: str,
|
|
1416
|
+
extras: Mapping[str, str],
|
|
1417
|
+
) -> str:
|
|
1418
|
+
split = urlsplit(endpoint)
|
|
1419
|
+
existing = parse_qsl(split.query, keep_blank_values=True)
|
|
1420
|
+
if any(name.lower() in _RESERVED_AUTH_PARAMS for name, _ in existing):
|
|
1421
|
+
raise OAuthValidationError("OAuth authorization endpoint contains reserved parameters.")
|
|
1422
|
+
params = [*existing, *extras.items()]
|
|
1423
|
+
params.extend(
|
|
1424
|
+
[
|
|
1425
|
+
("response_type", "code"),
|
|
1426
|
+
("client_id", client_id),
|
|
1427
|
+
("redirect_uri", redirect_uri),
|
|
1428
|
+
("scope", " ".join(scopes)),
|
|
1429
|
+
("state", state),
|
|
1430
|
+
("nonce", nonce),
|
|
1431
|
+
("code_challenge", code_challenge),
|
|
1432
|
+
("code_challenge_method", "S256"),
|
|
1433
|
+
]
|
|
1434
|
+
)
|
|
1435
|
+
result = urlunsplit((split.scheme, split.netloc, split.path, urlencode(params), ""))
|
|
1436
|
+
if len(result) > MAX_URL_LENGTH:
|
|
1437
|
+
raise OAuthValidationError("OAuth authorization URL exceeds the allowed length.")
|
|
1438
|
+
return result
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def _pkce_challenge(verifier: str) -> str:
|
|
1442
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
1443
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def _secret_digest(value: str) -> str:
|
|
1447
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _encode_scopes(scopes: tuple[str, ...]) -> str:
|
|
1451
|
+
return json.dumps(list(scopes), ensure_ascii=True, separators=(",", ":"))
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _decode_scopes(value: str) -> tuple[str, ...]:
|
|
1455
|
+
try:
|
|
1456
|
+
payload = json.loads(value)
|
|
1457
|
+
except (TypeError, ValueError) as exc:
|
|
1458
|
+
raise OAuthLifecycleError("OAuth flow registry contains invalid scope metadata.") from exc
|
|
1459
|
+
return _normalize_scopes(payload)
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def _validate_error_code(value: object) -> str:
|
|
1463
|
+
if not isinstance(value, str) or not _ERROR_CODE_RE.fullmatch(value):
|
|
1464
|
+
raise OAuthValidationError("OAuth error code is invalid.")
|
|
1465
|
+
return value
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def _bounded_duration(value: object, *, field_name: str, minimum: float, maximum: float) -> float:
|
|
1469
|
+
if (
|
|
1470
|
+
isinstance(value, bool)
|
|
1471
|
+
or not isinstance(value, (int, float))
|
|
1472
|
+
or not math.isfinite(float(value))
|
|
1473
|
+
or float(value) < minimum
|
|
1474
|
+
or float(value) > maximum
|
|
1475
|
+
):
|
|
1476
|
+
raise OAuthValidationError(f"OAuth {field_name} is outside the allowed range.")
|
|
1477
|
+
return float(value)
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _restrict_permissions(path: Path, *, directory: bool) -> None:
|
|
1481
|
+
if os.name == "nt":
|
|
1482
|
+
return
|
|
1483
|
+
try:
|
|
1484
|
+
path.chmod(0o700 if directory else 0o600)
|
|
1485
|
+
except OSError as exc:
|
|
1486
|
+
raise OAuthLifecycleError("OAuth flow registry permissions could not be secured.") from exc
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def _secure_precreate_database(path: Path) -> None:
|
|
1490
|
+
if path.is_symlink():
|
|
1491
|
+
raise OAuthLifecycleError("OAuth flow registry cannot use a symbolic link.")
|
|
1492
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_RDWR
|
|
1493
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
1494
|
+
flags |= os.O_NOFOLLOW
|
|
1495
|
+
try:
|
|
1496
|
+
descriptor = os.open(path, flags, 0o600)
|
|
1497
|
+
except FileExistsError:
|
|
1498
|
+
if path.is_symlink():
|
|
1499
|
+
raise OAuthLifecycleError("OAuth flow registry cannot use a symbolic link.") from None
|
|
1500
|
+
return
|
|
1501
|
+
except OSError as exc:
|
|
1502
|
+
raise OAuthLifecycleError("OAuth flow registry could not be created securely.") from exc
|
|
1503
|
+
else:
|
|
1504
|
+
os.close(descriptor)
|