codexLB-up 1.25.0b10__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.
- app/__init__.py +10 -0
- app/admin_cli.py +494 -0
- app/cli.py +339 -0
- app/codex_sessions_retag.py +487 -0
- app/core/__init__.py +0 -0
- app/core/audit/__init__.py +0 -0
- app/core/audit/service.py +216 -0
- app/core/audit/types.py +94 -0
- app/core/auth/__init__.py +236 -0
- app/core/auth/api_key_cache.py +65 -0
- app/core/auth/dashboard_access.py +429 -0
- app/core/auth/dashboard_mode.py +171 -0
- app/core/auth/dashboard_session_ttl.py +74 -0
- app/core/auth/dashboard_users_cache.py +120 -0
- app/core/auth/dependencies.py +710 -0
- app/core/auth/external_identity.py +78 -0
- app/core/auth/guardian.py +374 -0
- app/core/auth/models.py +49 -0
- app/core/auth/providers/__init__.py +129 -0
- app/core/auth/providers/oidc.py +838 -0
- app/core/auth/providers/registry.py +118 -0
- app/core/auth/refresh.py +394 -0
- app/core/auth/step_up.py +117 -0
- app/core/auth/totp.py +81 -0
- app/core/balancer/__init__.py +71 -0
- app/core/balancer/logic.py +1612 -0
- app/core/balancer/rendezvous_hash.py +21 -0
- app/core/balancer/types.py +21 -0
- app/core/bootstrap.py +172 -0
- app/core/cache/invalidation.py +426 -0
- app/core/clients/__init__.py +0 -0
- app/core/clients/codex.py +996 -0
- app/core/clients/codex_version.py +187 -0
- app/core/clients/codex_version_snapshot.py +2 -0
- app/core/clients/files.py +534 -0
- app/core/clients/headers.py +29 -0
- app/core/clients/http.py +511 -0
- app/core/clients/model_fetcher.py +276 -0
- app/core/clients/native_egress.py +1304 -0
- app/core/clients/oauth.py +477 -0
- app/core/clients/proxy.py +6089 -0
- app/core/clients/proxy_websocket.py +1411 -0
- app/core/clients/rate_limit_reset_credits.py +412 -0
- app/core/clients/stream_errors.py +12 -0
- app/core/clients/thread_cache_identity.py +347 -0
- app/core/clients/usage.py +467 -0
- app/core/clock.py +170 -0
- app/core/config/__init__.py +0 -0
- app/core/config/background_jobs.py +73 -0
- app/core/config/context_window_overrides.py +166 -0
- app/core/config/dashboard_overrides.py +145 -0
- app/core/config/inheritable.py +47 -0
- app/core/config/key_fingerprint.py +115 -0
- app/core/config/settings.py +872 -0
- app/core/config/settings_cache.py +100 -0
- app/core/config/spool_retention.py +217 -0
- app/core/config/tiers.py +174 -0
- app/core/conversation_archive.py +568 -0
- app/core/crypto.py +37 -0
- app/core/draining.py +11 -0
- app/core/errors.py +365 -0
- app/core/exceptions.py +155 -0
- app/core/handlers/__init__.py +3 -0
- app/core/handlers/exceptions.py +478 -0
- app/core/http_protocol.py +176 -0
- app/core/http_protocol_httptools.py +204 -0
- app/core/ingress_limits.py +17 -0
- app/core/metadata_store.py +18 -0
- app/core/metrics/__init__.py +0 -0
- app/core/metrics/middleware.py +74 -0
- app/core/metrics/prometheus.py +604 -0
- app/core/middleware/__init__.py +25 -0
- app/core/middleware/api_firewall.py +111 -0
- app/core/middleware/app_version.py +37 -0
- app/core/middleware/dashboard_auth_proxy.py +55 -0
- app/core/middleware/dashboard_csrf.py +177 -0
- app/core/middleware/dashboard_gzip.py +41 -0
- app/core/middleware/dashboard_overrides.py +59 -0
- app/core/middleware/firewall_cache.py +91 -0
- app/core/middleware/inflight.py +126 -0
- app/core/middleware/multipart_content_encoding.py +129 -0
- app/core/middleware/path_rewrite.py +148 -0
- app/core/middleware/request_body_limit.py +196 -0
- app/core/middleware/request_decompression.py +209 -0
- app/core/middleware/request_id.py +72 -0
- app/core/middleware/required_capability_http.py +116 -0
- app/core/middleware/trusted_proxy_headers.py +36 -0
- app/core/multipart.py +237 -0
- app/core/multipart_fields.py +91 -0
- app/core/openai/__init__.py +0 -0
- app/core/openai/chat_requests.py +493 -0
- app/core/openai/chat_responses.py +931 -0
- app/core/openai/contracts.py +46 -0
- app/core/openai/exceptions.py +24 -0
- app/core/openai/host_models.py +14 -0
- app/core/openai/images.py +413 -0
- app/core/openai/message_coercion.py +376 -0
- app/core/openai/model_refresh_scheduler.py +423 -0
- app/core/openai/model_registry.py +997 -0
- app/core/openai/model_registry_store.py +548 -0
- app/core/openai/models.py +172 -0
- app/core/openai/parsing.py +97 -0
- app/core/openai/requests.py +1980 -0
- app/core/openai/strict_schema.py +199 -0
- app/core/openai/tool_call_safety.py +99 -0
- app/core/openai/v1_requests.py +187 -0
- app/core/plan_types.py +90 -0
- app/core/prestop.py +202 -0
- app/core/rate_limiter/__init__.py +0 -0
- app/core/rate_limiter/db_rate_limiter.py +215 -0
- app/core/request_locality.py +473 -0
- app/core/resilience/__init__.py +0 -0
- app/core/resilience/backpressure.py +57 -0
- app/core/resilience/bulkhead.py +202 -0
- app/core/resilience/circuit_breaker.py +150 -0
- app/core/resilience/degradation.py +55 -0
- app/core/resilience/loop_lag_monitor.py +60 -0
- app/core/resilience/memory_monitor.py +137 -0
- app/core/resilience/network_recovery.py +256 -0
- app/core/resilience/overload.py +98 -0
- app/core/resilience/toggles.py +114 -0
- app/core/retention/__init__.py +0 -0
- app/core/retention/job.py +240 -0
- app/core/retention/scheduler.py +69 -0
- app/core/runtime_logging.py +601 -0
- app/core/scheduling/__init__.py +0 -0
- app/core/scheduling/leader_election.py +993 -0
- app/core/scheduling/leader_election_handle.py +25 -0
- app/core/server.py +140 -0
- app/core/shutdown.py +326 -0
- app/core/socket_peer.py +22 -0
- app/core/startup.py +41 -0
- app/core/timeout_invariants.py +347 -0
- app/core/tracing/__init__.py +0 -0
- app/core/tracing/otel.py +207 -0
- app/core/types.py +6 -0
- app/core/upstream_proxy/__init__.py +13 -0
- app/core/upstream_proxy/cache.py +130 -0
- app/core/upstream_proxy/resolver.py +189 -0
- app/core/upstream_proxy/types.py +104 -0
- app/core/usage/__init__.py +407 -0
- app/core/usage/depletion.py +109 -0
- app/core/usage/live_hub.py +41 -0
- app/core/usage/live_snapshots.py +174 -0
- app/core/usage/logs.py +212 -0
- app/core/usage/metadata_scheduler.py +118 -0
- app/core/usage/models.py +54 -0
- app/core/usage/pricing.py +576 -0
- app/core/usage/pricing_catalog.py +241 -0
- app/core/usage/pricing_snapshot.json +821 -0
- app/core/usage/quota.py +133 -0
- app/core/usage/refresh_policy.py +21 -0
- app/core/usage/refresh_scheduler.py +713 -0
- app/core/usage/reset_credits_refresh_scheduler.py +435 -0
- app/core/usage/types.py +168 -0
- app/core/utils/__init__.py +0 -0
- app/core/utils/json_guards.py +18 -0
- app/core/utils/locks.py +33 -0
- app/core/utils/masking.py +20 -0
- app/core/utils/proxy_env.py +104 -0
- app/core/utils/request_id.py +65 -0
- app/core/utils/retry.py +77 -0
- app/core/utils/shared_future.py +172 -0
- app/core/utils/sse.py +286 -0
- app/core/utils/time.py +24 -0
- app/db/__init__.py +0 -0
- app/db/account_identity_lock.py +56 -0
- app/db/alembic/__init__.py +1 -0
- app/db/alembic/env.py +70 -0
- app/db/alembic/revision_ids.py +43 -0
- app/db/alembic/versions/20260213_000000_base_schema.py +248 -0
- app/db/alembic/versions/20260213_000100_normalize_account_plan_types.py +45 -0
- app/db/alembic/versions/20260213_000200_add_request_logs_reasoning_effort.py +45 -0
- app/db/alembic/versions/20260213_000300_add_accounts_reset_at.py +45 -0
- app/db/alembic/versions/20260213_000400_add_accounts_chatgpt_account_id.py +45 -0
- app/db/alembic/versions/20260213_000500_add_dashboard_settings.py +70 -0
- app/db/alembic/versions/20260213_000600_add_dashboard_settings_totp.py +64 -0
- app/db/alembic/versions/20260213_000700_add_dashboard_settings_password.py +59 -0
- app/db/alembic/versions/20260213_000800_add_api_keys.py +87 -0
- app/db/alembic/versions/20260214_000000_add_api_key_limits.py +143 -0
- app/db/alembic/versions/20260215_000000_add_idx_logs_requested_at.py +38 -0
- app/db/alembic/versions/20260218_000000_add_api_key_usage_reservations.py +117 -0
- app/db/alembic/versions/20260218_000100_add_import_without_overwrite_and_drop_accounts_email_unique.py +198 -0
- app/db/alembic/versions/20260225_000000_add_dashboard_settings_routing_strategy.py +53 -0
- app/db/alembic/versions/20260228_020000_align_api_key_limit_enum_types.py +104 -0
- app/db/alembic/versions/20260228_030000_add_api_firewall_allowlist.py +42 -0
- app/db/alembic/versions/20260306_000000_add_request_logs_service_tier.py +45 -0
- app/db/alembic/versions/20260307_000000_add_api_key_enforcement_fields.py +56 -0
- app/db/alembic/versions/20260308_000000_add_sqlite_performance_indexes.py +41 -0
- app/db/alembic/versions/20260309_000000_add_additional_usage_history.py +71 -0
- app/db/alembic/versions/20260309_000000_request_logs_nullable_account_id.py +28 -0
- app/db/alembic/versions/20260310_000000_add_request_logs_transport.py +45 -0
- app/db/alembic/versions/20260310_000000_fix_postgresql_enum_value_casing.py +79 -0
- app/db/alembic/versions/20260310_120000_add_sticky_session_kinds_and_affinity_ttl.py +110 -0
- app/db/alembic/versions/20260312_000000_add_additional_usage_quota_key.py +193 -0
- app/db/alembic/versions/20260312_000000_split_sticky_sessions_primary_key_by_kind.py +96 -0
- app/db/alembic/versions/20260312_010000_merge_additional_usage_and_sticky_session_heads.py +25 -0
- app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_feature_heads.py +25 -0
- app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_sticky_heads.py +25 -0
- app/db/alembic/versions/20260312_120000_add_dashboard_upstream_stream_transport.py +62 -0
- app/db/alembic/versions/20260313_024500_merge_request_log_transport_heads.py +27 -0
- app/db/alembic/versions/20260319_100937_increase_prompt_cache_ttl_to_1800s.py +73 -0
- app/db/alembic/versions/20260319_111500_merge_prompt_cache_ttl_and_upstream_transport_heads.py +25 -0
- app/db/alembic/versions/20260319_183000_normalize_sqlite_account_status_casing.py +48 -0
- app/db/alembic/versions/20260320_000000_add_request_log_requested_actual_tiers.py +51 -0
- app/db/alembic/versions/20260321_190000_tighten_dashboard_db_indexes.py +64 -0
- app/db/alembic/versions/20260321_210000_merge_request_log_tiers_and_dashboard_index_heads.py +25 -0
- app/db/alembic/versions/20260325_000000_add_request_log_cost.py +193 -0
- app/db/alembic/versions/20260328_000000_add_rate_limit_attempts.py +48 -0
- app/db/alembic/versions/20260328_120000_add_request_log_ttft.py +37 -0
- app/db/alembic/versions/20260328_130000_add_audit_logs_table.py +45 -0
- app/db/alembic/versions/20260328_140000_add_scheduler_leader_table.py +39 -0
- app/db/alembic/versions/20260330_000000_add_cache_locality_settings.py +69 -0
- app/db/alembic/versions/20260330_010000_merge_scheduler_leader_and_cache_locality_heads.py +25 -0
- app/db/alembic/versions/20260330_020000_add_bridge_ring_members.py +46 -0
- app/db/alembic/versions/20260401_000000_add_api_key_enforced_service_tier.py +52 -0
- app/db/alembic/versions/20260401_000000_add_cache_invalidation.py +41 -0
- app/db/alembic/versions/20260401_020000_merge_cache_invalidation_and_api_key_service_tier_heads.py +22 -0
- app/db/alembic/versions/20260402_000000_switch_dashboard_routing_default_to_capacity_weighted.py +66 -0
- app/db/alembic/versions/20260403_000000_add_credit_api_key_limit_values.py +44 -0
- app/db/alembic/versions/20260406_000000_add_api_key_account_assignments.py +57 -0
- app/db/alembic/versions/20260406_010000_add_api_key_assignment_scope_flag.py +75 -0
- app/db/alembic/versions/20260407_000000_add_bridge_gateway_safe_mode.py +51 -0
- app/db/alembic/versions/20260407_010000_merge_api_key_assignment_and_bridge_gateway_heads.py +25 -0
- app/db/alembic/versions/20260408_000000_switch_import_without_overwrite_default_to_true.py +73 -0
- app/db/alembic/versions/20260408_010000_merge_import_without_overwrite_and_assignment_heads.py +25 -0
- app/db/alembic/versions/20260409_000000_add_http_bridge_sessions.py +130 -0
- app/db/alembic/versions/20260409_000000_switch_sticky_threads_and_prefer_earlier_reset_defaults_to_true.py +89 -0
- app/db/alembic/versions/20260409_010000_add_dashboard_settings_bootstrap_token.py +57 -0
- app/db/alembic/versions/20260409_010000_merge_http_bridge_and_import_without_overwrite_heads.py +25 -0
- app/db/alembic/versions/20260409_020000_fix_http_bridge_last_seen_index.py +46 -0
- app/db/alembic/versions/20260410_000000_backfill_pristine_dashboard_settings_defaults.py +77 -0
- app/db/alembic/versions/20260410_030000_restore_import_without_overwrite_default_true.py +73 -0
- app/db/alembic/versions/20260410_040000_merge_dashboard_defaults_and_import_default_heads.py +25 -0
- app/db/alembic/versions/20260413_000000_add_accounts_blocked_at.py +44 -0
- app/db/alembic/versions/20260415_160000_add_automations_tables.py +189 -0
- app/db/alembic/versions/20260415_160000_add_request_logs_response_lookup_index.py +67 -0
- app/db/alembic/versions/20260417_000000_add_request_log_plan_type.py +44 -0
- app/db/alembic/versions/20260418_190000_add_automation_runs_cycle_key.py +87 -0
- app/db/alembic/versions/20260419_000000_add_automation_run_cycle_metadata.py +56 -0
- app/db/alembic/versions/20260419_020000_add_automation_run_cycles_snapshot_tables.py +342 -0
- app/db/alembic/versions/20260421_000000_add_dashboard_session_ttl_setting.py +51 -0
- app/db/alembic/versions/20260421_120000_merge_request_log_lookup_and_plan_type_heads.py +25 -0
- app/db/alembic/versions/20260421_130000_merge_automation_and_request_log_heads.py +25 -0
- app/db/alembic/versions/20260422_103000_normalize_legacy_automation_run_cycle_keys.py +648 -0
- app/db/alembic/versions/20260423_120000_add_api_key_limit_reset_at_index.py +43 -0
- app/db/alembic/versions/20260424_000000_merge_dashboard_session_ttl_and_request_log_heads.py +25 -0
- app/db/alembic/versions/20260426_000000_add_dashboard_relative_availability_settings.py +68 -0
- app/db/alembic/versions/20260427_130000_add_warmup_model_and_request_kind.py +75 -0
- app/db/alembic/versions/20260506_000000_add_account_routing_policy.py +37 -0
- app/db/alembic/versions/20260509_000000_add_api_key_traffic_class.py +37 -0
- app/db/alembic/versions/20260509_010000_add_additional_quota_routing_policies.py +37 -0
- app/db/alembic/versions/20260513_000000_add_accounts_alias.py +44 -0
- app/db/alembic/versions/20260513_000000_add_api_key_apply_to_codex_model.py +59 -0
- app/db/alembic/versions/20260514_000000_add_request_logs_api_key_time_index.py +43 -0
- app/db/alembic/versions/20260515_000000_add_prefer_earlier_reset_window.py +47 -0
- app/db/alembic/versions/20260515_000000_soft_delete_request_logs_on_account_delete.py +83 -0
- app/db/alembic/versions/20260515_020000_add_split_sticky_budget_thresholds.py +62 -0
- app/db/alembic/versions/20260516_000000_add_sqlite_hot_path_indexes.py +75 -0
- app/db/alembic/versions/20260517_000000_merge_sqlite_hot_path_and_request_log_delete_heads.py +24 -0
- app/db/alembic/versions/20260518_000000_add_http_bridge_durable_input_prefix.py +44 -0
- app/db/alembic/versions/20260518_010000_merge_http_bridge_and_sqlite_recovery_heads.py +25 -0
- app/db/alembic/versions/20260518_120000_add_dashboard_guest_access.py +57 -0
- app/db/alembic/versions/20260520_000000_merge_api_key_and_http_bridge_heads.py +26 -0
- app/db/alembic/versions/20260520_010000_add_request_logs_api_key_account_index.py +45 -0
- app/db/alembic/versions/20260520_010000_merge_automation_and_api_key_heads.py +26 -0
- app/db/alembic/versions/20260520_030000_add_quota_planner.py +194 -0
- app/db/alembic/versions/20260520_040000_merge_quota_planner_and_request_log_index_heads.py +24 -0
- app/db/alembic/versions/20260521_000000_add_account_security_work_authorized.py +46 -0
- app/db/alembic/versions/20260521_000000_merge_dashboard_guest_and_api_key_heads.py +26 -0
- app/db/alembic/versions/20260521_000000_merge_prefer_reset_window_head.py +26 -0
- app/db/alembic/versions/20260521_000000_merge_warmup_and_request_log_index_heads.py +25 -0
- app/db/alembic/versions/20260522_000000_add_limit_warmup_trigger.py +195 -0
- app/db/alembic/versions/20260524_000000_merge_accounts_alias_and_reset_window_heads.py +24 -0
- app/db/alembic/versions/20260525_000000_add_usage_raw_window_latest_index.py +31 -0
- app/db/alembic/versions/20260526_000000_add_request_log_failure_metadata.py +100 -0
- app/db/alembic/versions/20260531_000000_add_account_workspace_identity.py +39 -0
- app/db/alembic/versions/20260531_000000_add_single_account_routing.py +42 -0
- app/db/alembic/versions/20260601_000000_merge_relative_availability_and_usage_raw_heads.py +24 -0
- app/db/alembic/versions/20260601_010000_add_api_key_usage_sections.py +58 -0
- app/db/alembic/versions/20260601_010000_add_routing_policy_persistence.py +67 -0
- app/db/alembic/versions/20260601_010000_merge_security_work_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_010000_merge_split_sticky_and_relative_usage_heads.py +25 -0
- app/db/alembic/versions/20260601_010000_merge_warmup_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_additional_quota_routing_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_quota_planner_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_reset_window_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_routing_policy_and_split_threshold_heads.py +24 -0
- app/db/alembic/versions/20260601_020000_merge_warmup_and_request_log_failure_heads.py +25 -0
- app/db/alembic/versions/20260601_030000_merge_single_account_and_routing_quota_heads.py +29 -0
- app/db/alembic/versions/20260601_040000_merge_security_work_and_routing_consolidation_heads.py +25 -0
- app/db/alembic/versions/20260601_050000_merge_quota_planner_and_routing_consolidation_heads.py +25 -0
- app/db/alembic/versions/20260602_050000_add_upstream_proxy_routing.py +164 -0
- app/db/alembic/versions/20260602_060000_merge_account_workspace_and_failure_heads.py +25 -0
- app/db/alembic/versions/20260602_070000_add_request_log_useragent_fields.py +86 -0
- app/db/alembic/versions/20260602_070000_merge_quota_planner_and_account_workspace_heads.py +25 -0
- app/db/alembic/versions/20260602_080000_merge_upstream_proxy_and_quota_planner_heads.py +25 -0
- app/db/alembic/versions/20260602_080000_merge_useragent_and_upstream_proxy_heads.py +24 -0
- app/db/alembic/versions/20260603_000000_add_weekly_pace_working_days.py +52 -0
- app/db/alembic/versions/20260603_000000_free_account_monthly_window.py +60 -0
- app/db/alembic/versions/20260604_000000_add_reauth_required_account_status.py +159 -0
- app/db/alembic/versions/20260604_010000_merge_automations_and_reauth_status_heads.py +25 -0
- app/db/alembic/versions/20260604_010000_merge_dashboard_guest_and_reauth_status_heads.py +25 -0
- app/db/alembic/versions/20260604_010000_merge_useragent_and_reauth_heads.py +24 -0
- app/db/alembic/versions/20260607_000000_merge_weekly_monthly_useragent_heads.py +28 -0
- app/db/alembic/versions/20260608_000000_add_hide_upstream_quota_from_api_keys.py +50 -0
- app/db/alembic/versions/20260611_000000_add_request_log_archive_request_id.py +47 -0
- app/db/alembic/versions/20260611_000000_merge_automations_and_weekly_monthly_useragent_heads.py +26 -0
- app/db/alembic/versions/20260611_000000_merge_dashboard_guest_and_weekly_useragent_heads.py +26 -0
- app/db/alembic/versions/20260611_010000_add_request_log_client_ip.py +57 -0
- app/db/alembic/versions/20260611_020000_merge_request_log_archive_and_client_ip_heads.py +26 -0
- app/db/alembic/versions/20260611_030000_merge_dashboard_guest_and_request_log_client_ip_heads.py +26 -0
- app/db/alembic/versions/20260613_000000_add_accounts_codex_installation_id.py +57 -0
- app/db/alembic/versions/20260613_010000_merge_installation_id_and_dashboard_weekly_heads.py +26 -0
- app/db/alembic/versions/20260615_000000_merge_automations_and_dashboard_guest_heads.py +26 -0
- app/db/alembic/versions/20260623_000000_add_limit_warmup_exhausted_threshold.py +51 -0
- app/db/alembic/versions/20260626_000000_add_http_downstream_transport_policy.py +46 -0
- app/db/alembic/versions/20260626_010000_add_request_logs_upstream_transport.py +31 -0
- app/db/alembic/versions/20260629_000000_add_dashboard_query_hot_path_indexes.py +53 -0
- app/db/alembic/versions/20260630_000000_add_limit_warmup_staggered_idle.py +47 -0
- app/db/alembic/versions/20260630_000000_merge_request_log_client_ip_and_dashboard_hot_path_heads.py +26 -0
- app/db/alembic/versions/20260630_010000_merge_warmup_and_request_log_dashboard_heads.py +26 -0
- app/db/alembic/versions/20260630_020000_merge_automations_and_main_heads.py +26 -0
- app/db/alembic/versions/20260630_020000_merge_warmup_threshold_and_main_heads.py +26 -0
- app/db/alembic/versions/20260630_030000_add_automation_run_model_snapshot.py +59 -0
- app/db/alembic/versions/20260630_040000_merge_automation_snapshot_and_warmup_threshold_heads.py +26 -0
- app/db/alembic/versions/20260630_050000_add_automation_run_prompt_snapshot.py +52 -0
- app/db/alembic/versions/20260701_000000_add_weekly_pace_smoothing_minutes.py +51 -0
- app/db/alembic/versions/20260702_000000_add_model_sources.py +176 -0
- app/db/alembic/versions/20260705_000000_add_model_source_audio_transcriptions.py +50 -0
- app/db/alembic/versions/20260705_000000_harden_dashboard_session_ttl.py +62 -0
- app/db/alembic/versions/20260706_000000_add_model_source_audio_per_minute.py +43 -0
- app/db/alembic/versions/20260707_000000_merge_model_sources_and_session_ttl.py +24 -0
- app/db/alembic/versions/20260707_010000_merge_automations_and_model_sources_heads.py +24 -0
- app/db/alembic/versions/20260707_020000_add_automation_job_account_scope.py +125 -0
- app/db/alembic/versions/20260709_000000_add_ttft_phase_observability.py +66 -0
- app/db/alembic/versions/20260711_000000_add_dashboard_prohibit_fast_mode.py +42 -0
- app/db/alembic/versions/20260711_010000_add_account_chatgpt_user_id.py +40 -0
- app/db/alembic/versions/20260711_020000_add_dashboard_account_concurrency_caps.py +71 -0
- app/db/alembic/versions/20260711_030000_add_limit_warmup_idle_threshold.py +51 -0
- app/db/alembic/versions/20260712_010000_add_account_usage_rollups.py +56 -0
- app/db/alembic/versions/20260712_020000_add_api_key_usage_rollups.py +53 -0
- app/db/alembic/versions/20260713_020000_add_model_registry_snapshot.py +42 -0
- app/db/alembic/versions/20260713_040000_add_account_refresh_claims.py +42 -0
- app/db/alembic/versions/20260713_040000_add_replica_guardrails.py +64 -0
- app/db/alembic/versions/20260713_070000_add_reset_credit_redeem_tables.py +58 -0
- app/db/alembic/versions/20260714_000000_add_oauth_flow_states.py +63 -0
- app/db/alembic/versions/20260715_000000_add_request_log_queue_latency.py +44 -0
- app/db/alembic/versions/20260716_000000_add_oauth_device_flow_slots.py +41 -0
- app/db/alembic/versions/20260716_010000_add_dashboard_retention_settings.py +48 -0
- app/db/alembic/versions/20260716_010000_add_reset_credit_display_settings.py +73 -0
- app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py +47 -0
- app/db/alembic/versions/20260717_000000_merge_retention_and_reset_credit_display_heads.py +26 -0
- app/db/alembic/versions/20260717_000000_optimize_dashboard_hot_path_indexes.py +162 -0
- app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py +22 -0
- app/db/alembic/versions/20260720_000000_add_request_log_conversation_id.py +57 -0
- app/db/alembic/versions/20260722_000000_backfill_request_log_useragent_families.py +40 -0
- app/db/alembic/versions/20260724_000000_add_request_usage_time_rollups.py +176 -0
- app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py +33 -0
- app/db/alembic/versions/20260725_000000_add_http_bridge_pending_tool_calls.py +43 -0
- app/db/alembic/versions/20260726_000000_add_account_plan_downgrade_observations.py +44 -0
- app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py +37 -0
- app/db/alembic/versions/20260727_000000_add_sticky_session_continuity_abandoned_at.py +45 -0
- app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py +31 -0
- app/db/alembic/versions/20260730_000000_add_api_key_fair_share_threshold.py +44 -0
- app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py +60 -0
- app/db/alembic/versions/20260731_000000_add_capability_lineage_markers.py +38 -0
- app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py +30 -0
- app/db/alembic/versions/20260804_000000_add_http_bridge_operations.py +63 -0
- app/db/alembic/versions/20260804_000001_add_global_http_bridge_operation_fingerprint.py +53 -0
- app/db/alembic/versions/20260804_230000_add_request_log_connection_request_kind.py +43 -0
- app/db/alembic/versions/20260805_000000_add_http_bridge_operation_spool.py +75 -0
- app/db/alembic/versions/20260805_000001_finalize_http_bridge_operation_spool.py +65 -0
- app/db/alembic/versions/20260806_000000_add_additional_usage_alias_probe_indexes.py +84 -0
- app/db/alembic/versions/20260806_000000_add_anonymous_telemetry.py +49 -0
- app/db/alembic/versions/20260806_010000_add_conversation_presence_rollup.py +72 -0
- app/db/alembic/versions/20260806_020000_add_usage_history_bulk_covering_indexes.py +99 -0
- app/db/alembic/versions/20260806_030000_add_api_key_allowed_reasoning_efforts.py +65 -0
- app/db/alembic/versions/20260806_120000_add_http_bridge_owner_process_epoch.py +57 -0
- app/db/alembic/versions/20260807_000000_merge_http_bridge_operation_spool_and_latest_main.py +24 -0
- app/db/alembic/versions/20260808_000000_tune_usage_history_autovacuum.py +54 -0
- app/db/alembic/versions/20260810_000000_add_http_bridge_recovery_dispatch_count.py +44 -0
- app/db/alembic/versions/20260811_000000_add_hourly_rollup_cancelled_count.py +92 -0
- app/db/alembic/versions/20260812_000000_merge_recovery_dispatch_and_hourly_cancelled_heads.py +25 -0
- app/db/alembic/versions/20260812_120000_add_sticky_abandonment_scope.py +43 -0
- app/db/alembic/versions/20260813_000000_add_file_account_pins.py +40 -0
- app/db/alembic/versions/20260816_000000_add_account_pending_deletion.py +81 -0
- app/db/alembic/versions/20260816_000000_add_model_source_embeddings.py +50 -0
- app/db/alembic/versions/20260821_000000_add_retry_circuit_admission_generation.py +49 -0
- app/db/alembic/versions/20260826_000000_add_http_bridge_event_chunks.py +107 -0
- app/db/alembic/versions/20260828_000000_add_accounts_chatgpt_identity_index.py +55 -0
- app/db/alembic/versions/20260830_000000_add_quota_warmup_claim_expiry.py +83 -0
- app/db/alembic/versions/20260908_000000_add_guest_session_generation.py +37 -0
- app/db/alembic/versions/20260908_000000_add_subscription_overflow.py +140 -0
- app/db/alembic/versions/20260908_000000_replace_upstream_stream_transport_default_sentinel.py +50 -0
- app/db/alembic/versions/20260908_020000_merge_overflow_transport_heads.py +27 -0
- app/db/alembic/versions/20260909_000000_add_dashboard_roles.py +72 -0
- app/db/alembic/versions/20260909_010000_add_dashboard_users.py +183 -0
- app/db/alembic/versions/20260909_020000_reproject_compat_admin_credentials.py +105 -0
- app/db/alembic/versions/20260909_030000_add_audit_actor_columns.py +103 -0
- app/db/alembic/versions/20260909_030000_dashboard_resilience_toggle_settings.py +63 -0
- app/db/alembic/versions/20260909_040000_add_dashboard_user_invites.py +54 -0
- app/db/alembic/versions/20260909_040000_dashboard_timeout_settings.py +68 -0
- app/db/alembic/versions/20260909_050000_dashboard_routing_overload_settings.py +65 -0
- app/db/alembic/versions/20260909_060000_add_report_rollup.py +74 -0
- app/db/alembic/versions/20260909_070000_automation_run_claim_budget.py +50 -0
- app/db/alembic/versions/20260909_080000_dashboard_stream_bridge_budgets.py +62 -0
- app/db/alembic/versions/20260909_090000_dashboard_background_job_toggles.py +63 -0
- app/db/alembic/versions/20260909_100000_dashboard_codex_prewarm.py +49 -0
- app/db/alembic/versions/20260909_110000_model_context_window_overrides.py +45 -0
- app/db/alembic/versions/20260909_120000_dashboard_conversation_archive.py +48 -0
- app/db/alembic/versions/20260909_130000_add_request_logs_live_facet_indexes.py +96 -0
- app/db/alembic/versions/20260910_000000_add_totp_required_for_admin_role.py +44 -0
- app/db/alembic/versions/20260910_000000_request_logs_missing_cost_index.py +52 -0
- app/db/alembic/versions/20260910_010000_add_dashboard_auth_providers.py +134 -0
- app/db/alembic/versions/20260910_010000_dashboard_spool_retention.py +52 -0
- app/db/alembic/versions/20260910_020000_add_dashboard_role_mappings.py +74 -0
- app/db/alembic/versions/20260910_143000_request_log_affinity.py +28 -0
- app/db/alembic/versions/20260910_180000_merge_affinity_guest_heads.py +17 -0
- app/db/alembic/versions/20260910_200000_merge_affinity_identity_heads.py +17 -0
- app/db/alembic/versions/20260910_220000_merge_affinity_invite_heads.py +17 -0
- app/db/alembic/versions/20260911_000000_model_source_pins_kind_expires_index.py +43 -0
- app/db/alembic/versions/20260911_010000_merge_pin_index_and_affinity_heads.py +17 -0
- app/db/alembic/versions/20260911_020000_add_http_bridge_terminal_append_phase.py +54 -0
- app/db/alembic/versions/20260911_030000_add_local_login_policy.py +48 -0
- app/db/alembic/versions/20260911_040000_add_thread_cache_identity_mode.py +48 -0
- app/db/alembic/versions/20260911_060000_add_bridge_session_continuity_abandonment.py +40 -0
- app/db/alembic/versions/20260912_000000_merge_thread_cache_and_bridge_retirement_heads.py +22 -0
- app/db/alembic/versions/20260912_010000_drop_legacy_dashboard_credentials.py +140 -0
- app/db/alembic/versions/20260913_000000_add_oidc_provider_flow.py +134 -0
- app/db/alembic/versions/20260914_000000_add_scim_tokens.py +78 -0
- app/db/alembic/versions/20260914_000000_drop_subscription_overflow_schema.py +132 -0
- app/db/alembic/versions/20260918_000000_merge_scim_and_overflow_heads.py +17 -0
- app/db/alembic/versions/__init__.py +1 -0
- app/db/backup.py +64 -0
- app/db/migrate.py +1249 -0
- app/db/migration_lock.py +160 -0
- app/db/migration_url.py +18 -0
- app/db/models.py +3100 -0
- app/db/recover.py +244 -0
- app/db/session.py +1295 -0
- app/db/snapshot.py +46 -0
- app/db/sqlite_lock_retry.py +190 -0
- app/db/sqlite_utils.py +549 -0
- app/dependencies.py +462 -0
- app/main.py +1240 -0
- app/modules/__init__.py +0 -0
- app/modules/accounts/__init__.py +0 -0
- app/modules/accounts/api.py +426 -0
- app/modules/accounts/auth_manager.py +1439 -0
- app/modules/accounts/background_repository.py +160 -0
- app/modules/accounts/deletion.py +540 -0
- app/modules/accounts/mappers.py +654 -0
- app/modules/accounts/refresh_claims.py +352 -0
- app/modules/accounts/repository.py +1679 -0
- app/modules/accounts/schemas.py +308 -0
- app/modules/accounts/service.py +809 -0
- app/modules/accounts/usage_rollup.py +486 -0
- app/modules/accounts/usage_rollup_scheduler.py +75 -0
- app/modules/accounts/usage_time_rollup.py +1037 -0
- app/modules/accounts/usage_time_rollup_read.py +522 -0
- app/modules/api_keys/__init__.py +1 -0
- app/modules/api_keys/api.py +344 -0
- app/modules/api_keys/last_used_coalescer.py +216 -0
- app/modules/api_keys/limit_windows.py +41 -0
- app/modules/api_keys/repository.py +1200 -0
- app/modules/api_keys/reset_scheduler.py +85 -0
- app/modules/api_keys/schemas.py +135 -0
- app/modules/api_keys/service.py +2123 -0
- app/modules/audit/__init__.py +0 -0
- app/modules/audit/api.py +88 -0
- app/modules/audit/repository.py +74 -0
- app/modules/audit/schemas.py +33 -0
- app/modules/audit/service.py +71 -0
- app/modules/auth_providers/__init__.py +0 -0
- app/modules/auth_providers/api.py +127 -0
- app/modules/auth_providers/config.py +343 -0
- app/modules/auth_providers/repository.py +101 -0
- app/modules/auth_providers/schemas.py +88 -0
- app/modules/auth_providers/seed.py +105 -0
- app/modules/auth_providers/service.py +289 -0
- app/modules/automations/__init__.py +1 -0
- app/modules/automations/api.py +369 -0
- app/modules/automations/repository.py +1896 -0
- app/modules/automations/scheduler.py +107 -0
- app/modules/automations/schemas.py +158 -0
- app/modules/automations/service.py +2548 -0
- app/modules/cache_isolation_probe/__init__.py +0 -0
- app/modules/cache_isolation_probe/api.py +188 -0
- app/modules/cache_isolation_probe/prefix.py +144 -0
- app/modules/cache_isolation_probe/schemas.py +94 -0
- app/modules/cache_isolation_probe/sender.py +229 -0
- app/modules/cache_isolation_probe/service.py +525 -0
- app/modules/conversation_archive/__init__.py +1 -0
- app/modules/conversation_archive/api.py +101 -0
- app/modules/conversation_archive/schemas.py +28 -0
- app/modules/conversation_archive/service.py +165 -0
- app/modules/dashboard/__init__.py +1 -0
- app/modules/dashboard/api.py +106 -0
- app/modules/dashboard/builders.py +107 -0
- app/modules/dashboard/repository.py +304 -0
- app/modules/dashboard/schemas.py +148 -0
- app/modules/dashboard/service.py +534 -0
- app/modules/dashboard/timeframes.py +19 -0
- app/modules/dashboard/weekly_pace.py +617 -0
- app/modules/dashboard_auth/__init__.py +1 -0
- app/modules/dashboard_auth/api.py +1344 -0
- app/modules/dashboard_auth/oidc_api.py +753 -0
- app/modules/dashboard_auth/oidc_flows.py +330 -0
- app/modules/dashboard_auth/repository.py +452 -0
- app/modules/dashboard_auth/schemas.py +232 -0
- app/modules/dashboard_auth/service.py +1384 -0
- app/modules/dashboard_roles/__init__.py +0 -0
- app/modules/dashboard_roles/api.py +64 -0
- app/modules/dashboard_roles/repository.py +54 -0
- app/modules/dashboard_roles/schemas.py +29 -0
- app/modules/dashboard_roles/seed.py +80 -0
- app/modules/dashboard_roles/service.py +142 -0
- app/modules/dashboard_users/__init__.py +0 -0
- app/modules/dashboard_users/api.py +278 -0
- app/modules/dashboard_users/break_glass.py +169 -0
- app/modules/dashboard_users/credentials.py +18 -0
- app/modules/dashboard_users/identity_resolver.py +665 -0
- app/modules/dashboard_users/repository.py +725 -0
- app/modules/dashboard_users/schemas.py +117 -0
- app/modules/dashboard_users/service.py +1054 -0
- app/modules/firewall/__init__.py +1 -0
- app/modules/firewall/api.py +78 -0
- app/modules/firewall/repository.py +51 -0
- app/modules/firewall/schemas.py +28 -0
- app/modules/firewall/service.py +93 -0
- app/modules/fleet/__init__.py +1 -0
- app/modules/fleet/api.py +177 -0
- app/modules/fleet/mappers.py +56 -0
- app/modules/fleet/observability.py +372 -0
- app/modules/fleet/schemas.py +119 -0
- app/modules/health/__init__.py +0 -0
- app/modules/health/api.py +246 -0
- app/modules/health/schemas.py +27 -0
- app/modules/limit_warmup/__init__.py +1 -0
- app/modules/limit_warmup/repository.py +159 -0
- app/modules/limit_warmup/service.py +1001 -0
- app/modules/model_sources/__init__.py +1 -0
- app/modules/model_sources/api.py +99 -0
- app/modules/model_sources/catalog.py +307 -0
- app/modules/model_sources/forwarding.py +1435 -0
- app/modules/model_sources/projection.py +55 -0
- app/modules/model_sources/repository.py +201 -0
- app/modules/model_sources/schemas.py +79 -0
- app/modules/model_sources/selection.py +166 -0
- app/modules/model_sources/service.py +236 -0
- app/modules/oauth/__init__.py +0 -0
- app/modules/oauth/api.py +101 -0
- app/modules/oauth/repository.py +238 -0
- app/modules/oauth/schemas.py +49 -0
- app/modules/oauth/service.py +1196 -0
- app/modules/oauth/templates/oauth_success.html +122 -0
- app/modules/proxy/__init__.py +0 -0
- app/modules/proxy/_load_balancer/__init__.py +1 -0
- app/modules/proxy/_load_balancer/error_rate.py +116 -0
- app/modules/proxy/_load_balancer/latency_cohort.py +154 -0
- app/modules/proxy/_load_balancer/model_eligibility.py +250 -0
- app/modules/proxy/_load_balancer/opportunistic_admission.py +350 -0
- app/modules/proxy/_load_balancer/overload_backoff.py +394 -0
- app/modules/proxy/_load_balancer/sticky_selection.py +2417 -0
- app/modules/proxy/_load_balancer/throughput_cohort.py +233 -0
- app/modules/proxy/_load_balancer/ttft_cohort.py +214 -0
- app/modules/proxy/_load_balancer/tunables.py +97 -0
- app/modules/proxy/_load_balancer/types.py +96 -0
- app/modules/proxy/_load_balancer/unbound_selection.py +534 -0
- app/modules/proxy/_service/__init__.py +6 -0
- app/modules/proxy/_service/api_key_usage.py +745 -0
- app/modules/proxy/_service/clock_budget.py +38 -0
- app/modules/proxy/_service/codex_control.py +611 -0
- app/modules/proxy/_service/compact.py +2211 -0
- app/modules/proxy/_service/file_ops.py +749 -0
- app/modules/proxy/_service/http_bridge/__init__.py +3 -0
- app/modules/proxy/_service/http_bridge/accepted_replay.py +331 -0
- app/modules/proxy/_service/http_bridge/account_sessions.py +57 -0
- app/modules/proxy/_service/http_bridge/activity.py +178 -0
- app/modules/proxy/_service/http_bridge/helpers.py +3959 -0
- app/modules/proxy/_service/http_bridge/mixin.py +2435 -0
- app/modules/proxy/_service/http_bridge/owner_forwarding.py +609 -0
- app/modules/proxy/_service/http_bridge/protocol.py +167 -0
- app/modules/proxy/_service/http_bridge/proxy_failover.py +58 -0
- app/modules/proxy/_service/http_bridge/quarantine.py +443 -0
- app/modules/proxy/_service/http_bridge/request_submit.py +4582 -0
- app/modules/proxy/_service/http_bridge/retry_circuit.py +2442 -0
- app/modules/proxy/_service/http_bridge/service_stubs.py +492 -0
- app/modules/proxy/_service/http_bridge/session_registry.py +778 -0
- app/modules/proxy/_service/http_bridge/streaming.py +5328 -0
- app/modules/proxy/_service/http_bridge/upstream_events.py +4538 -0
- app/modules/proxy/_service/observability.py +410 -0
- app/modules/proxy/_service/rate_limit.py +361 -0
- app/modules/proxy/_service/realtime_live.py +655 -0
- app/modules/proxy/_service/refresh.py +154 -0
- app/modules/proxy/_service/request_log.py +585 -0
- app/modules/proxy/_service/response_create.py +930 -0
- app/modules/proxy/_service/streaming/__init__.py +3 -0
- app/modules/proxy/_service/streaming/helpers.py +1317 -0
- app/modules/proxy/_service/streaming/mixin.py +1100 -0
- app/modules/proxy/_service/streaming/protocol.py +32 -0
- app/modules/proxy/_service/streaming/retry.py +3762 -0
- app/modules/proxy/_service/support.py +2122 -0
- app/modules/proxy/_service/transcribe.py +434 -0
- app/modules/proxy/_service/warmup.py +483 -0
- app/modules/proxy/_service/websocket/__init__.py +3 -0
- app/modules/proxy/_service/websocket/helpers.py +2360 -0
- app/modules/proxy/_service/websocket/mixin.py +7285 -0
- app/modules/proxy/_service/websocket/protocol.py +74 -0
- app/modules/proxy/account_cache.py +229 -0
- app/modules/proxy/account_eligibility.py +87 -0
- app/modules/proxy/additional_model_limits.py +40 -0
- app/modules/proxy/affinity.py +830 -0
- app/modules/proxy/affinity_observation.py +101 -0
- app/modules/proxy/api.py +10254 -0
- app/modules/proxy/api_key_usage.py +113 -0
- app/modules/proxy/cap_partitioning.py +280 -0
- app/modules/proxy/capability_lineage.py +65 -0
- app/modules/proxy/capability_lineage_repository.py +82 -0
- app/modules/proxy/capability_routing.py +253 -0
- app/modules/proxy/continuity.py +112 -0
- app/modules/proxy/downstream_delivery.py +277 -0
- app/modules/proxy/durable_bridge_coordinator.py +1217 -0
- app/modules/proxy/durable_bridge_repository.py +4516 -0
- app/modules/proxy/durable_bridge_runtime.py +9 -0
- app/modules/proxy/durable_bridge_transcript_codec.py +122 -0
- app/modules/proxy/fair_share.py +131 -0
- app/modules/proxy/file_pin_repository.py +210 -0
- app/modules/proxy/helpers.py +439 -0
- app/modules/proxy/http_bridge_event_batcher.py +613 -0
- app/modules/proxy/http_bridge_forwarding.py +797 -0
- app/modules/proxy/http_continuation.py +76 -0
- app/modules/proxy/images_observability.py +62 -0
- app/modules/proxy/images_service.py +1054 -0
- app/modules/proxy/load_balancer.py +3016 -0
- app/modules/proxy/rate_limit_cache.py +46 -0
- app/modules/proxy/replay_safety.py +1063 -0
- app/modules/proxy/repo_bundle.py +31 -0
- app/modules/proxy/request_policy.py +1054 -0
- app/modules/proxy/ring_membership.py +251 -0
- app/modules/proxy/schemas.py +345 -0
- app/modules/proxy/selection_errors.py +41 -0
- app/modules/proxy/service.py +2600 -0
- app/modules/proxy/source_admission.py +125 -0
- app/modules/proxy/source_dispatch.py +999 -0
- app/modules/proxy/sticky_repository.py +831 -0
- app/modules/proxy/thread_anchors.py +491 -0
- app/modules/proxy/tool_call_dedupe.py +802 -0
- app/modules/proxy/types.py +54 -0
- app/modules/proxy/work_admission.py +121 -0
- app/modules/quota_planner/__init__.py +1 -0
- app/modules/quota_planner/api.py +276 -0
- app/modules/quota_planner/logic.py +743 -0
- app/modules/quota_planner/repository.py +722 -0
- app/modules/quota_planner/scheduler.py +212 -0
- app/modules/quota_planner/schemas.py +102 -0
- app/modules/quota_planner/warmup.py +693 -0
- app/modules/rate_limit_reset_credits/__init__.py +0 -0
- app/modules/rate_limit_reset_credits/api.py +601 -0
- app/modules/rate_limit_reset_credits/redeem_coordination.py +274 -0
- app/modules/rate_limit_reset_credits/store.py +114 -0
- app/modules/reports/__init__.py +1 -0
- app/modules/reports/api.py +134 -0
- app/modules/reports/cache.py +74 -0
- app/modules/reports/filters.py +27 -0
- app/modules/reports/repository.py +525 -0
- app/modules/reports/rollup.py +150 -0
- app/modules/reports/rollup_read.py +148 -0
- app/modules/reports/schemas.py +123 -0
- app/modules/reports/service.py +287 -0
- app/modules/reports/thread_identity.py +256 -0
- app/modules/request_logs/__init__.py +0 -0
- app/modules/request_logs/api.py +224 -0
- app/modules/request_logs/cost_backfill.py +154 -0
- app/modules/request_logs/mappers.py +97 -0
- app/modules/request_logs/repository.py +1765 -0
- app/modules/request_logs/schemas.py +145 -0
- app/modules/request_logs/service.py +348 -0
- app/modules/role_mappings/__init__.py +0 -0
- app/modules/role_mappings/api.py +206 -0
- app/modules/role_mappings/matching.py +55 -0
- app/modules/role_mappings/repository.py +106 -0
- app/modules/role_mappings/schemas.py +67 -0
- app/modules/role_mappings/service.py +334 -0
- app/modules/runtime/__init__.py +1 -0
- app/modules/runtime/api.py +18 -0
- app/modules/runtime/schemas.py +14 -0
- app/modules/runtime/service.py +218 -0
- app/modules/scim/__init__.py +1 -0
- app/modules/scim/api.py +194 -0
- app/modules/scim/dependencies.py +112 -0
- app/modules/scim/errors.py +77 -0
- app/modules/scim/management_api.py +156 -0
- app/modules/scim/repository.py +212 -0
- app/modules/scim/schemas.py +145 -0
- app/modules/scim/service.py +786 -0
- app/modules/scim/tokens.py +43 -0
- app/modules/settings/__init__.py +1 -0
- app/modules/settings/api.py +1677 -0
- app/modules/settings/repository.py +606 -0
- app/modules/settings/schemas.py +496 -0
- app/modules/settings/service.py +769 -0
- app/modules/shared/__init__.py +0 -0
- app/modules/shared/schemas.py +22 -0
- app/modules/sticky_sessions/__init__.py +1 -0
- app/modules/sticky_sessions/api.py +118 -0
- app/modules/sticky_sessions/cleanup_scheduler.py +487 -0
- app/modules/sticky_sessions/schemas.py +78 -0
- app/modules/sticky_sessions/service.py +196 -0
- app/modules/telemetry/__init__.py +5 -0
- app/modules/telemetry/api.py +115 -0
- app/modules/telemetry/clients.py +62 -0
- app/modules/telemetry/consent.py +115 -0
- app/modules/telemetry/scheduler.py +85 -0
- app/modules/telemetry/schemas.py +163 -0
- app/modules/telemetry/sender.py +221 -0
- app/modules/telemetry/snapshot.py +490 -0
- app/modules/usage/__init__.py +0 -0
- app/modules/usage/additional_quota_keys.py +379 -0
- app/modules/usage/api.py +45 -0
- app/modules/usage/background_repository.py +147 -0
- app/modules/usage/builders.py +559 -0
- app/modules/usage/depletion_service.py +277 -0
- app/modules/usage/live_ingest.py +356 -0
- app/modules/usage/mappers.py +29 -0
- app/modules/usage/plan_downgrade_observations.py +488 -0
- app/modules/usage/repository.py +1890 -0
- app/modules/usage/schemas.py +68 -0
- app/modules/usage/service.py +102 -0
- app/modules/usage/updater.py +1488 -0
- app/static/assets/Trans-Cj-J9mVA.js +1 -0
- app/static/assets/access-page-DHzZiXZi.js +1 -0
- app/static/assets/account-cost-donut-DGhfA4U8.js +1 -0
- app/static/assets/account-identifiers-CxbdLshZ.js +1 -0
- app/static/assets/account-status-DIsU5sUS.js +1 -0
- app/static/assets/account-trend-chart-BptlfwwQ.js +1 -0
- app/static/assets/accounts-page-Bbai8ift.js +3 -0
- app/static/assets/api-Dwa7XxPe.js +1 -0
- app/static/assets/api-client-dugX1Pti.js +55 -0
- app/static/assets/api-key-create-dialog-DnRm0oYN.js +1 -0
- app/static/assets/api-key-created-dialog-B6jkDG9f.js +1 -0
- app/static/assets/api-key-edit-dialog-Ck3Bnc3V.js +1 -0
- app/static/assets/api-trend-chart-BchCw7Hj.js +1 -0
- app/static/assets/apis-page-BvXz9c6i.js +2 -0
- app/static/assets/arrow-up-Dqh_KzJo.js +1 -0
- app/static/assets/arrow-up-down-LBRgVDGY.js +1 -0
- app/static/assets/automations-page-DQgNAJOR.js +3 -0
- app/static/assets/badge-DqMb9es0.js +1 -0
- app/static/assets/blur-email-HEvKagG2.js +1 -0
- app/static/assets/button-BjVmmiGW.js +1 -0
- app/static/assets/checkbox-apsl9vzk.js +1 -0
- app/static/assets/chevron-left-1d_pHVa5.js +1 -0
- app/static/assets/circle-alert-BS0xd_o1.js +1 -0
- app/static/assets/clsx-DB0hHKMi.js +1 -0
- app/static/assets/colors-BBy8GEtr.js +1 -0
- app/static/assets/copy-button-D7nAvOKR.js +1 -0
- app/static/assets/cost-per-day-chart-YdqlkZVM.js +1 -0
- app/static/assets/createLucideIcon-DBLzLNh2.js +1 -0
- app/static/assets/dashboard-page-CSdhBy0f.js +2 -0
- app/static/assets/distribution-metric-format-yZTy1ZEu.js +1 -0
- app/static/assets/donut-chart-Bxc69cra.js +1 -0
- app/static/assets/download-kQteIcsB.js +1 -0
- app/static/assets/ellipsis-B_5OgIre.js +1 -0
- app/static/assets/empty-state-JcA7tc8m.js +1 -0
- app/static/assets/es2015-bMH8YKZT.js +41 -0
- app/static/assets/es6-B264ni6W.js +69 -0
- app/static/assets/external-link-UVAOgMQ0.js +1 -0
- app/static/assets/i18n-BWqZ9Ua2.js +1 -0
- app/static/assets/inbox-B_RM2s1J.js +1 -0
- app/static/assets/index-DIpd264Z.js +23 -0
- app/static/assets/index-DMeLKRJx.css +2 -0
- app/static/assets/inherit-badge-QhTYVaU6.js +1 -0
- app/static/assets/invite-dialog-0CYh0GiQ.js +1 -0
- app/static/assets/iso-Bpe5Ihbb.js +1 -0
- app/static/assets/label-CGaLmLNf.js +1 -0
- app/static/assets/lazy-recharts-Pv8p9ArM.js +2 -0
- app/static/assets/loading-overlay-CzKQfHLI.js +1 -0
- app/static/assets/mini-quota-bar-cGT_t8Ae.js +1 -0
- app/static/assets/model-distribution-donut-BKjAfT48.js +1 -0
- app/static/assets/model-source-multi-select-D2d06Vc7.js +1 -0
- app/static/assets/multi-select-filter-KIA5qU6Z.js +1 -0
- app/static/assets/oauth-dialog-XeaGGDjS.js +1 -0
- app/static/assets/pagination-controls-D1Ypd0RU.js +1 -0
- app/static/assets/payload-CIYnGZz9.js +1 -0
- app/static/assets/queue-wait-chart-VoV7Yg9L.js +1 -0
- app/static/assets/report-chart-card-BXDliwcc.js +1 -0
- app/static/assets/reports-page-CGCJeMHG.js +3 -0
- app/static/assets/reset-credit-confirm-dialog-QOifTnVB.js +1 -0
- app/static/assets/rolldown-runtime-hePW80VL.js +1 -0
- app/static/assets/search-HfhqwERt.js +1 -0
- app/static/assets/select-Cl33bz-g.js +1 -0
- app/static/assets/settings-page-mZj01HYl.js +2 -0
- app/static/assets/shim-CZwyM4Q7.js +1 -0
- app/static/assets/skeleton-C_EUc4Rp.js +1 -0
- app/static/assets/sparkline-chart-DdNNsddM.js +1 -0
- app/static/assets/status-badge-CHHA5X6c.js +1 -0
- app/static/assets/switch-CzxgokcM.js +1 -0
- app/static/assets/table-DXaNSQ0G.js +1 -0
- app/static/assets/time-to-first-token-chart--68L0u_v.js +1 -0
- app/static/assets/tokens-per-day-chart-Cs82BFuE.js +1 -0
- app/static/assets/tokens-per-second-chart-AD2dfOn6.js +1 -0
- app/static/assets/totp-settings-CRHtxQ7j.js +1 -0
- app/static/assets/trash-D3KyWr1h.js +1 -0
- app/static/assets/use-account-quota-display-BPmrTBTC.js +1 -0
- app/static/assets/use-accounts-PWlVGw3z.js +1 -0
- app/static/assets/use-auth-CuDlW3iC.js +1 -0
- app/static/assets/use-chart-colors-DIu6tClI.js +1 -0
- app/static/assets/use-model-sources-Bul24JBu.js +1 -0
- app/static/assets/use-models-DUCPUuZg.js +1 -0
- app/static/assets/use-reduced-motion-hAm5r6_d.js +1 -0
- app/static/assets/useTranslation--56nHhlH.js +1 -0
- app/static/assets/useragent-distribution-donut-Dup6sf_n.js +1 -0
- app/static/assets/users-uIC3TIlE.js +1 -0
- app/static/assets/vendor-query-_bHZuxEx.js +1 -0
- app/static/assets/vendor-react-BVL0YZPW.js +9 -0
- app/static/favicon.svg +10 -0
- app/static/fonts/GeistSans-Variable.woff2 +0 -0
- app/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- app/static/fonts/jetbrains-mono-latin.woff2 +0 -0
- app/static/index.html +29 -0
- codexlb_up-1.25.0b10.dist-info/METADATA +124 -0
- codexlb_up-1.25.0b10.dist-info/RECORD +827 -0
- codexlb_up-1.25.0b10.dist-info/WHEEL +4 -0
- codexlb_up-1.25.0b10.dist-info/entry_points.txt +3 -0
- codexlb_up-1.25.0b10.dist-info/licenses/LICENSE +21 -0
- config/additional_quota_registry.json +11 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
"""The public OIDC routes, mounted on the dashboard-auth router.
|
|
2
|
+
|
|
3
|
+
They live in their own module for reading, not for routing: they are added to
|
|
4
|
+
the very same ``/api/dashboard-auth`` router the password routes use, so they
|
|
5
|
+
inherit its error format, its session exemption and its place in the
|
|
6
|
+
cross-site origin middleware's view of the world without a new prefix or a new
|
|
7
|
+
exemption list.
|
|
8
|
+
|
|
9
|
+
Both routes are ``GET``. That is a security decision, not a style one: the
|
|
10
|
+
cross-site origin middleware runs before routing and refuses any unsafe
|
|
11
|
+
``/api/`` request that arrives cross-site, which is exactly what the identity
|
|
12
|
+
provider's callback is. A ``form_post`` callback would be answered ``403``
|
|
13
|
+
before the handler existed, and the "fix" -- exempting this prefix -- would
|
|
14
|
+
exempt session-minting routes. The query response mode makes the question
|
|
15
|
+
disappear.
|
|
16
|
+
|
|
17
|
+
Nothing here takes a destination from the caller. Where the browser lands is
|
|
18
|
+
derived from the flow's stored purpose, out of a closed set of in-app paths, so
|
|
19
|
+
there is no ``next``, no ``return_to`` and no way to turn this into an open
|
|
20
|
+
redirect.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hmac
|
|
26
|
+
import secrets
|
|
27
|
+
from typing import Final
|
|
28
|
+
|
|
29
|
+
from fastapi import Depends, Request, Response
|
|
30
|
+
from fastapi.responses import JSONResponse, RedirectResponse
|
|
31
|
+
|
|
32
|
+
from app.core.audit.service import AuditActor, AuditService, AuditTarget
|
|
33
|
+
from app.core.audit.types import AuditAuthMethod, AuditSeverity
|
|
34
|
+
from app.core.auth.dashboard_access import DashboardPrincipal, Permission
|
|
35
|
+
from app.core.auth.dashboard_session_ttl import REMOTE_DASHBOARD_SESSION_TTL_SECONDS
|
|
36
|
+
from app.core.auth.dependencies import (
|
|
37
|
+
ensure_dashboard_permission,
|
|
38
|
+
require_dashboard_permission,
|
|
39
|
+
validate_dashboard_session,
|
|
40
|
+
)
|
|
41
|
+
from app.core.auth.providers import DEFAULT_PROVIDER_KEY, ExternalIdentity
|
|
42
|
+
from app.core.auth.providers.oidc import (
|
|
43
|
+
OIDC_CLOCK_SKEW_SECONDS,
|
|
44
|
+
OidcCompletion,
|
|
45
|
+
OidcError,
|
|
46
|
+
get_oidc_provider,
|
|
47
|
+
hash_flow_value,
|
|
48
|
+
)
|
|
49
|
+
from app.core.auth.providers.registry import get_auth_provider_registry
|
|
50
|
+
from app.core.auth.step_up import STEP_UP_MAX_AGE_SECONDS, step_up_methods
|
|
51
|
+
from app.core.clients.oauth import generate_pkce_pair
|
|
52
|
+
from app.core.config.settings import get_settings
|
|
53
|
+
from app.core.exceptions import (
|
|
54
|
+
AppError,
|
|
55
|
+
DashboardAuthError,
|
|
56
|
+
DashboardConflictError,
|
|
57
|
+
DashboardNotFoundError,
|
|
58
|
+
DashboardRateLimitError,
|
|
59
|
+
DashboardUpstreamError,
|
|
60
|
+
)
|
|
61
|
+
from app.core.utils.masking import mask_email
|
|
62
|
+
from app.core.utils.time import utcnow
|
|
63
|
+
from app.db.models import AuthProviderKind, DashboardAuthProvider, DashboardUser
|
|
64
|
+
from app.dependencies import DashboardAuthContext, get_dashboard_auth_context
|
|
65
|
+
from app.modules.auth_providers.config import OidcProviderConfig, load_oidc_config, oidc_config_fingerprint
|
|
66
|
+
from app.modules.auth_providers.repository import AuthProvidersRepository
|
|
67
|
+
from app.modules.dashboard_auth.api import (
|
|
68
|
+
_client_host,
|
|
69
|
+
_create_user_session,
|
|
70
|
+
_rate_limit_error,
|
|
71
|
+
_session_client_key,
|
|
72
|
+
_set_session_cookie,
|
|
73
|
+
record_step_up_on,
|
|
74
|
+
router,
|
|
75
|
+
)
|
|
76
|
+
from app.modules.dashboard_auth.oidc_flows import (
|
|
77
|
+
OIDC_FLOW_COOKIE,
|
|
78
|
+
OIDC_FLOW_COOKIE_PATH,
|
|
79
|
+
OIDC_FLOW_TTL_SECONDS,
|
|
80
|
+
OIDC_LOGIN_START_PATH,
|
|
81
|
+
OidcFlowPurpose,
|
|
82
|
+
OidcFlowRecord,
|
|
83
|
+
OidcFlowRepository,
|
|
84
|
+
clear_pending_marker,
|
|
85
|
+
get_oidc_flow_cookie_store,
|
|
86
|
+
set_pending_marker,
|
|
87
|
+
)
|
|
88
|
+
from app.modules.dashboard_auth.schemas import OidcStartResponse
|
|
89
|
+
from app.modules.dashboard_auth.service import (
|
|
90
|
+
get_login_failed_audit_rate_limiter,
|
|
91
|
+
get_oidc_address_rate_limiter,
|
|
92
|
+
get_oidc_state_rate_limiter,
|
|
93
|
+
session_clock,
|
|
94
|
+
)
|
|
95
|
+
from app.modules.dashboard_users.identity_resolver import ResolvedAccount, get_identity_resolution_cache
|
|
96
|
+
from app.modules.dashboard_users.repository import DashboardUsersRepository
|
|
97
|
+
|
|
98
|
+
#: The closed set of places this flow can send a browser. Every one is a
|
|
99
|
+
#: relative in-app path, so no absolute URL can appear even by accident.
|
|
100
|
+
OIDC_DASHBOARD_PATH: Final[str] = "/dashboard"
|
|
101
|
+
OIDC_PENDING_PATH: Final[str] = "/auth/pending"
|
|
102
|
+
#: Where a completed pre-flight or re-authentication lands: the card that
|
|
103
|
+
#: started it, through the settings page's existing deep-link convention.
|
|
104
|
+
OIDC_SETTINGS_PATH: Final[str] = "/settings?org=1#oidc"
|
|
105
|
+
#: One failure destination for every refusal that is not "no account here", and
|
|
106
|
+
#: a constant marker rather than a reason: an unauthenticated caller must not be
|
|
107
|
+
#: able to tell a bad state from a bad nonce from a refused exchange.
|
|
108
|
+
OIDC_FAILURE_PATH: Final[str] = "/login?sso=failed"
|
|
109
|
+
|
|
110
|
+
#: Derived from the advertised path so the route and the login screen's
|
|
111
|
+
#: ``login_url`` cannot drift apart.
|
|
112
|
+
_LOGIN_START_ROUTE: Final[str] = OIDC_LOGIN_START_PATH.removeprefix(router.prefix)
|
|
113
|
+
_REDIRECT_STATUS: Final[int] = 303
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _redirect(path: str) -> RedirectResponse:
|
|
117
|
+
response = RedirectResponse(path, status_code=_REDIRECT_STATUS)
|
|
118
|
+
_clear_flow_cookie(response)
|
|
119
|
+
# Every destination except the pending screen clears the refusal marker:
|
|
120
|
+
# a completed sign-in, a completed pre-flight, a completed
|
|
121
|
+
# re-authentication and every failure are all answers to "who are you"
|
|
122
|
+
# that supersede the last refusal this browser was handed.
|
|
123
|
+
clear_pending_marker(response)
|
|
124
|
+
return response
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _pending_redirect(request: Request, row: DashboardAuthProvider, identity: ExternalIdentity) -> RedirectResponse:
|
|
128
|
+
"""The one destination that carries something back, and only to the browser it refused.
|
|
129
|
+
|
|
130
|
+
The person behind this browser has just authenticated at the company
|
|
131
|
+
identity provider and holds no session, so the screen would otherwise have
|
|
132
|
+
nothing to say. The marker names the row (the screen resolves its label,
|
|
133
|
+
which is public: it is what the sign-in button says) and the masked address
|
|
134
|
+
the identity provider asserted, which the administrator's refused sign-ins
|
|
135
|
+
list masks the same way -- so the two strings match by eye. Nothing else
|
|
136
|
+
goes in: no subject, no groups, no claim, no address in clear.
|
|
137
|
+
|
|
138
|
+
An identity provider that asserted no address leaves no marker. There would
|
|
139
|
+
be no reference to quote and the audit row it would be matched against has
|
|
140
|
+
no address either, so the screen keeps its general copy instead of naming a
|
|
141
|
+
provider with nothing to look up.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
email = (identity.email or "").strip()
|
|
145
|
+
if not email:
|
|
146
|
+
return _redirect(OIDC_PENDING_PATH)
|
|
147
|
+
response = RedirectResponse(OIDC_PENDING_PATH, status_code=_REDIRECT_STATUS)
|
|
148
|
+
_clear_flow_cookie(response)
|
|
149
|
+
set_pending_marker(response, request, provider_id=row.id, reference=mask_email(email))
|
|
150
|
+
return response
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _set_flow_cookie(response: Response, request: Request, state: str) -> None:
|
|
154
|
+
response.set_cookie(
|
|
155
|
+
key=OIDC_FLOW_COOKIE,
|
|
156
|
+
value=get_oidc_flow_cookie_store().create(state),
|
|
157
|
+
httponly=True,
|
|
158
|
+
secure=request.url.scheme == "https",
|
|
159
|
+
# Lax, not Strict: the callback is a top-level cross-site navigation
|
|
160
|
+
# and Strict would drop the cookie on the one request that needs it.
|
|
161
|
+
samesite="lax",
|
|
162
|
+
max_age=OIDC_FLOW_TTL_SECONDS,
|
|
163
|
+
path=OIDC_FLOW_COOKIE_PATH,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _clear_flow_cookie(response: RedirectResponse) -> None:
|
|
168
|
+
"""Cleared with the same attributes it was set with, however the flow ends."""
|
|
169
|
+
|
|
170
|
+
response.delete_cookie(key=OIDC_FLOW_COOKIE, path=OIDC_FLOW_COOKIE_PATH)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def _active_oidc_row() -> tuple[DashboardAuthProvider, OidcProviderConfig] | None:
|
|
174
|
+
"""The enabled row the current auth mode admits, together with a configuration that opens.
|
|
175
|
+
|
|
176
|
+
A row that is disabled, a mode that does not admit it and a blob that does
|
|
177
|
+
not decrypt are one outcome, deliberately: none of them can sign anyone in,
|
|
178
|
+
and an unauthenticated caller learns nothing from the difference.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
active = await get_auth_provider_registry().get_active(
|
|
182
|
+
AuthProviderKind.OIDC, DEFAULT_PROVIDER_KEY, get_settings().dashboard_auth_mode
|
|
183
|
+
)
|
|
184
|
+
if active is None:
|
|
185
|
+
return None
|
|
186
|
+
config = load_oidc_config(active.row)
|
|
187
|
+
return None if config is None else (active.row, config)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _configured_oidc_row(
|
|
191
|
+
context: DashboardAuthContext, *, provider_id: str | None = None
|
|
192
|
+
) -> tuple[DashboardAuthProvider, OidcProviderConfig] | None:
|
|
193
|
+
"""The OIDC row as the **pre-flight** sees it: configured, but not necessarily enabled.
|
|
194
|
+
|
|
195
|
+
A test sign-in exists to prove a connection before an operator turns it on,
|
|
196
|
+
so it is the one path that must work against a disabled row. It still reads
|
|
197
|
+
the row itself (not the registry, which only holds active providers), and
|
|
198
|
+
it still refuses a row nobody has configured.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
repository = AuthProvidersRepository(context.session)
|
|
202
|
+
row = (
|
|
203
|
+
await repository.get_provider(provider_id)
|
|
204
|
+
if provider_id is not None
|
|
205
|
+
else await repository.get_by_kind(AuthProviderKind.OIDC.value, DEFAULT_PROVIDER_KEY)
|
|
206
|
+
)
|
|
207
|
+
if row is None or row.kind != AuthProviderKind.OIDC.value:
|
|
208
|
+
return None
|
|
209
|
+
config = load_oidc_config(row)
|
|
210
|
+
return None if config is None else (row, config)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def _audit_failure(request: Request, context: DashboardAuthContext, *, stage: str) -> None:
|
|
214
|
+
"""One bounded ``login_failed`` row per refusal, carrying no value from the wire.
|
|
215
|
+
|
|
216
|
+
``stage`` is a fixed label from this module. The code, the state, the nonce
|
|
217
|
+
and every token are deliberately absent: the audit sanitizer redacts none of
|
|
218
|
+
those key names, so keeping them out is the only thing that keeps them out.
|
|
219
|
+
The public route also cannot be allowed to become an unbounded row source,
|
|
220
|
+
so the same budget the anonymous password refusals spend applies here.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
await get_login_failed_audit_rate_limiter().check_and_increment(
|
|
225
|
+
_session_client_key(request, prefix="login_failed_audit"), context.session
|
|
226
|
+
)
|
|
227
|
+
except DashboardRateLimitError:
|
|
228
|
+
return
|
|
229
|
+
AuditService.log_async(
|
|
230
|
+
"login_failed",
|
|
231
|
+
actor_ip=_client_host(request),
|
|
232
|
+
details={
|
|
233
|
+
"method": AuditAuthMethod.OIDC.value,
|
|
234
|
+
"username": None,
|
|
235
|
+
"reason": "oidc_login_failed",
|
|
236
|
+
"stage": stage,
|
|
237
|
+
},
|
|
238
|
+
severity=AuditSeverity.WARNING,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def _failure(request: Request, context: DashboardAuthContext, *, stage: str) -> RedirectResponse:
|
|
243
|
+
await _audit_failure(request, context, stage=stage)
|
|
244
|
+
return _redirect(OIDC_FAILURE_PATH)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
async def _begin_flow(
|
|
248
|
+
context: DashboardAuthContext,
|
|
249
|
+
*,
|
|
250
|
+
row: DashboardAuthProvider,
|
|
251
|
+
config: OidcProviderConfig,
|
|
252
|
+
purpose: OidcFlowPurpose,
|
|
253
|
+
acting_user_id: str | None = None,
|
|
254
|
+
prompt_login: bool = False,
|
|
255
|
+
) -> tuple[str, str]:
|
|
256
|
+
"""Mint one round trip: the authorization URL, and the ``state`` its browser must carry.
|
|
257
|
+
|
|
258
|
+
Every flow — sign-in, pre-flight, re-authentication — is created here, so
|
|
259
|
+
there is one place where the ``state``, the ``nonce`` and the PKCE verifier
|
|
260
|
+
are generated, one place where only their hashes are stored, and one place
|
|
261
|
+
that takes the redirect URI from the row instead of from the request.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
state, nonce = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
|
265
|
+
verifier, challenge = generate_pkce_pair()
|
|
266
|
+
authorization_url = await get_oidc_provider().begin_login(
|
|
267
|
+
provider=row,
|
|
268
|
+
config=config,
|
|
269
|
+
state=state,
|
|
270
|
+
nonce=nonce,
|
|
271
|
+
code_challenge=challenge,
|
|
272
|
+
prompt_login=prompt_login,
|
|
273
|
+
)
|
|
274
|
+
flows = OidcFlowRepository(context.session)
|
|
275
|
+
await flows.purge_expired()
|
|
276
|
+
await flows.create(
|
|
277
|
+
state_hash=hash_flow_value(state),
|
|
278
|
+
provider_id=row.id,
|
|
279
|
+
nonce_hash=hash_flow_value(nonce),
|
|
280
|
+
code_verifier=verifier,
|
|
281
|
+
purpose=purpose,
|
|
282
|
+
acting_user_id=acting_user_id,
|
|
283
|
+
# Sent from the stored configuration and repeated from the row at the
|
|
284
|
+
# exchange: nothing validates or rewrites ``Host``, so a redirect URI
|
|
285
|
+
# built from the request would be attacker-controllable.
|
|
286
|
+
redirect_uri=config.redirect_uri,
|
|
287
|
+
# Which connection document this round trip is about. The callback
|
|
288
|
+
# refuses a flow whose provider has been reconfigured since, and the
|
|
289
|
+
# pre-flight's proof is stamped only against this same document.
|
|
290
|
+
config_fingerprint=oidc_config_fingerprint(config),
|
|
291
|
+
)
|
|
292
|
+
return authorization_url, state
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def _spend_start_budget(request: Request, context: DashboardAuthContext) -> None:
|
|
296
|
+
try:
|
|
297
|
+
await get_oidc_address_rate_limiter().check_and_increment(
|
|
298
|
+
_session_client_key(request, prefix="oidc_start"), context.session
|
|
299
|
+
)
|
|
300
|
+
except DashboardRateLimitError as exc:
|
|
301
|
+
raise _rate_limit_error(exc, code="oidc_rate_limited") from exc
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _start_response(authorization_url: str, request: Request, state: str) -> JSONResponse:
|
|
305
|
+
"""The authorization URL as JSON, with the flow cookie that binds it to this browser."""
|
|
306
|
+
|
|
307
|
+
body = OidcStartResponse(authorization_url=authorization_url)
|
|
308
|
+
response = JSONResponse(status_code=200, content=body.model_dump(by_alias=True))
|
|
309
|
+
_set_flow_cookie(response, request, state)
|
|
310
|
+
return response
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@router.get(_LOGIN_START_ROUTE, include_in_schema=False)
|
|
314
|
+
async def start_oidc_login(
|
|
315
|
+
request: Request,
|
|
316
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
317
|
+
) -> RedirectResponse:
|
|
318
|
+
"""Public: begin a sign-in at the configured identity provider.
|
|
319
|
+
|
|
320
|
+
Login CSRF is not a hole worth a token here: forcing a victim's browser to
|
|
321
|
+
*begin* its own sign-in achieves nothing, and finishing a flow an attacker
|
|
322
|
+
started in the victim's browser is what the flow cookie refuses.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
await _spend_start_budget(request, context)
|
|
326
|
+
configured = await _active_oidc_row()
|
|
327
|
+
if configured is None:
|
|
328
|
+
raise DashboardNotFoundError("No single sign-on provider is available", code="provider_not_found")
|
|
329
|
+
row, config = configured
|
|
330
|
+
try:
|
|
331
|
+
authorization_url, state = await _begin_flow(context, row=row, config=config, purpose=OidcFlowPurpose.LOGIN)
|
|
332
|
+
except OidcError as exc:
|
|
333
|
+
return await _failure(request, context, stage=exc.stage)
|
|
334
|
+
response = RedirectResponse(authorization_url, status_code=_REDIRECT_STATUS)
|
|
335
|
+
_set_flow_cookie(response, request, state)
|
|
336
|
+
return response
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@router.post("/oidc/test-login/start", response_model=OidcStartResponse)
|
|
340
|
+
async def start_oidc_test_login(
|
|
341
|
+
request: Request,
|
|
342
|
+
principal: DashboardPrincipal = Depends(require_dashboard_permission(Permission.SECURITY_WRITE)),
|
|
343
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
344
|
+
) -> JSONResponse:
|
|
345
|
+
"""Prove a connection before turning it on: the admin's own browser makes the round trip.
|
|
346
|
+
|
|
347
|
+
Deliberately runs against the row as stored, enabled or not — a pre-flight
|
|
348
|
+
that needed the provider to be on already would prove nothing, since
|
|
349
|
+
turning it on is what it guards. The proof it produces belongs to this
|
|
350
|
+
account (``principal.user_id``) and expires in ten minutes, so a connection
|
|
351
|
+
that stops working cannot be enabled from an old success.
|
|
352
|
+
|
|
353
|
+
An identity provider this server cannot reach answers ``502`` rather than
|
|
354
|
+
the sign-in flow's uniform failure: the caller is the admin who typed the
|
|
355
|
+
URL, telling them it does not resolve is the entire point of a pre-flight,
|
|
356
|
+
and nothing from the identity provider's own response is reflected.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
if principal.user_id is None:
|
|
360
|
+
raise DashboardConflictError(
|
|
361
|
+
"Set a dashboard password and sign in before connecting single sign-on",
|
|
362
|
+
code="admin_account_required",
|
|
363
|
+
)
|
|
364
|
+
await _spend_start_budget(request, context)
|
|
365
|
+
configured = await _configured_oidc_row(context)
|
|
366
|
+
if configured is None:
|
|
367
|
+
raise DashboardNotFoundError("No single sign-on provider is configured", code="provider_not_found")
|
|
368
|
+
row, config = configured
|
|
369
|
+
try:
|
|
370
|
+
authorization_url, state = await _begin_flow(
|
|
371
|
+
context,
|
|
372
|
+
row=row,
|
|
373
|
+
config=config,
|
|
374
|
+
purpose=OidcFlowPurpose.TEST,
|
|
375
|
+
acting_user_id=principal.user_id,
|
|
376
|
+
)
|
|
377
|
+
except OidcError as exc:
|
|
378
|
+
raise DashboardUpstreamError(
|
|
379
|
+
"The identity provider could not be reached with these settings", code="oidc_provider_unreachable"
|
|
380
|
+
) from exc
|
|
381
|
+
return _start_response(authorization_url, request, state)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@router.post("/oidc/step-up/start", response_model=OidcStartResponse)
|
|
385
|
+
async def start_oidc_step_up(
|
|
386
|
+
request: Request,
|
|
387
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
388
|
+
) -> JSONResponse:
|
|
389
|
+
"""Re-authenticate at the identity provider as a step-up factor (PLAN §4.6, H5).
|
|
390
|
+
|
|
391
|
+
For the account whose only credential *is* its identity provider. It asks
|
|
392
|
+
for ``prompt=login`` and ``max_age=0``, and the callback refuses anything
|
|
393
|
+
the identity provider does not stamp with a fresh ``auth_time`` — otherwise
|
|
394
|
+
"re-authenticate" would be satisfied by a provider session the attacker's
|
|
395
|
+
stolen cookie already rides alongside.
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
principal = await validate_dashboard_session(request)
|
|
399
|
+
if principal.user_id is None:
|
|
400
|
+
raise DashboardAuthError("A user account session is required", code="user_account_required")
|
|
401
|
+
await _spend_start_budget(request, context)
|
|
402
|
+
configured = await _active_oidc_row()
|
|
403
|
+
if configured is None:
|
|
404
|
+
raise DashboardNotFoundError("No single sign-on provider is available", code="provider_not_found")
|
|
405
|
+
row, config = configured
|
|
406
|
+
try:
|
|
407
|
+
authorization_url, state = await _begin_flow(
|
|
408
|
+
context,
|
|
409
|
+
row=row,
|
|
410
|
+
config=config,
|
|
411
|
+
purpose=OidcFlowPurpose.STEP_UP,
|
|
412
|
+
acting_user_id=principal.user_id,
|
|
413
|
+
prompt_login=True,
|
|
414
|
+
)
|
|
415
|
+
except OidcError as exc:
|
|
416
|
+
raise DashboardUpstreamError(
|
|
417
|
+
"The identity provider could not be reached", code="oidc_provider_unreachable"
|
|
418
|
+
) from exc
|
|
419
|
+
return _start_response(authorization_url, request, state)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@router.get("/oidc/callback", include_in_schema=False)
|
|
423
|
+
async def complete_oidc_login(
|
|
424
|
+
request: Request,
|
|
425
|
+
state: str | None = None,
|
|
426
|
+
code: str | None = None,
|
|
427
|
+
error: str | None = None,
|
|
428
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
429
|
+
) -> RedirectResponse:
|
|
430
|
+
"""Public: finish the round trip, or fail the same way for every reason.
|
|
431
|
+
|
|
432
|
+
``error`` is accepted so a refusal at the identity provider is not a 422,
|
|
433
|
+
and is then ignored: nothing the identity provider says about why is
|
|
434
|
+
reflected to the browser, to a log line or to an audit row.
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
await get_oidc_address_rate_limiter().check_and_increment(
|
|
439
|
+
_session_client_key(request, prefix="oidc_callback"), context.session
|
|
440
|
+
)
|
|
441
|
+
except DashboardRateLimitError as exc:
|
|
442
|
+
raise _rate_limit_error(exc, code="oidc_rate_limited") from exc
|
|
443
|
+
|
|
444
|
+
cookie_state = get_oidc_flow_cookie_store().get(request.cookies.get(OIDC_FLOW_COOKIE))
|
|
445
|
+
# Compared as hashes rather than as the values: constant time over a fixed
|
|
446
|
+
# length, and ``compare_digest`` refuses non-ASCII text, which a caller can
|
|
447
|
+
# put in a query parameter but never in one of our sealed cookies.
|
|
448
|
+
state_hash = "" if state is None else hash_flow_value(state)
|
|
449
|
+
if cookie_state is None or state is None or not hmac.compare_digest(hash_flow_value(cookie_state), state_hash):
|
|
450
|
+
# The browser that started this flow is the only one that may finish
|
|
451
|
+
# it; a state without its cookie is somebody else's (or nobody's).
|
|
452
|
+
return await _failure(request, context, stage="state")
|
|
453
|
+
|
|
454
|
+
state_key = f"oidc_callback_state:{state_hash}"
|
|
455
|
+
try:
|
|
456
|
+
await get_oidc_state_rate_limiter().check_and_increment(state_key, context.session)
|
|
457
|
+
except DashboardRateLimitError as exc:
|
|
458
|
+
raise _rate_limit_error(exc, code="oidc_rate_limited") from exc
|
|
459
|
+
|
|
460
|
+
flows = OidcFlowRepository(context.session)
|
|
461
|
+
# The point of no return, and it happens BEFORE the exchange: exactly one
|
|
462
|
+
# caller in the fleet wins this row, so a replayed authorization code can
|
|
463
|
+
# never reach the token endpoint twice through this system.
|
|
464
|
+
record = await flows.consume(state_hash)
|
|
465
|
+
if record is None:
|
|
466
|
+
return await _failure(request, context, stage="flow")
|
|
467
|
+
if error is not None or not code:
|
|
468
|
+
return await _failure(request, context, stage="authorization")
|
|
469
|
+
try:
|
|
470
|
+
purpose = OidcFlowPurpose(record.purpose)
|
|
471
|
+
except ValueError: # pragma: no cover - the column is only ever written from the enum
|
|
472
|
+
return await _failure(request, context, stage="purpose")
|
|
473
|
+
|
|
474
|
+
# A pre-flight runs against the row as stored, because proving a connection
|
|
475
|
+
# is what precedes enabling it. A sign-in and a re-authentication need the
|
|
476
|
+
# provider to be active right now, and to still be the same row the flow
|
|
477
|
+
# was started against.
|
|
478
|
+
configured = (
|
|
479
|
+
await _configured_oidc_row(context, provider_id=record.provider_id)
|
|
480
|
+
if purpose is OidcFlowPurpose.TEST
|
|
481
|
+
else await _active_oidc_row()
|
|
482
|
+
)
|
|
483
|
+
if (
|
|
484
|
+
configured is None
|
|
485
|
+
or configured[0].id != record.provider_id
|
|
486
|
+
or oidc_config_fingerprint(configured[1]) != record.config_fingerprint
|
|
487
|
+
):
|
|
488
|
+
# The provider was disabled, reconfigured or repointed while the person
|
|
489
|
+
# was at the identity provider. A flow completes against the connection
|
|
490
|
+
# document it was started against or not at all: the code was issued by
|
|
491
|
+
# that identity provider, and a pre-flight proves the configuration it
|
|
492
|
+
# actually reached rather than whichever one the row holds when the
|
|
493
|
+
# browser comes back.
|
|
494
|
+
return await _failure(request, context, stage="provider")
|
|
495
|
+
row, config = configured
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
completion = await get_oidc_provider().complete_login(
|
|
499
|
+
provider=row,
|
|
500
|
+
config=config,
|
|
501
|
+
code=code,
|
|
502
|
+
code_verifier=record.code_verifier,
|
|
503
|
+
# Repeated from the row, not re-derived: RFC 6749 requires the
|
|
504
|
+
# token request to carry the same redirect URI the authorization
|
|
505
|
+
# request did, and a configuration edit mid-flow must not change it
|
|
506
|
+
# underneath the exchange.
|
|
507
|
+
redirect_uri=record.redirect_uri,
|
|
508
|
+
nonce_hash=record.nonce_hash,
|
|
509
|
+
flow_created_at=record.created_at_epoch,
|
|
510
|
+
)
|
|
511
|
+
except OidcError as exc:
|
|
512
|
+
return await _failure(request, context, stage=exc.stage)
|
|
513
|
+
|
|
514
|
+
if purpose is OidcFlowPurpose.LOGIN:
|
|
515
|
+
finished, completed = await _complete_sign_in(request, context, row, completion)
|
|
516
|
+
elif purpose is OidcFlowPurpose.TEST:
|
|
517
|
+
finished, completed = await _complete_test_login(request, context, row, record, completion)
|
|
518
|
+
else:
|
|
519
|
+
finished, completed = await _complete_step_up(request, context, row, record, completion)
|
|
520
|
+
if completed:
|
|
521
|
+
# Only the per-state budget is cleared. The per-address ceiling is the
|
|
522
|
+
# endpoint's only bound on how many flows one client may burn, and
|
|
523
|
+
# handing it to anyone who can complete one round trip would remove it.
|
|
524
|
+
await get_oidc_state_rate_limiter().clear_for_key(state_key, context.session)
|
|
525
|
+
return finished
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
async def _complete_sign_in(
|
|
529
|
+
request: Request,
|
|
530
|
+
context: DashboardAuthContext,
|
|
531
|
+
row: DashboardAuthProvider,
|
|
532
|
+
completion: OidcCompletion,
|
|
533
|
+
) -> tuple[RedirectResponse, bool]:
|
|
534
|
+
"""The ordinary sign-in: the shared resolver decides who this is, or that it is nobody.
|
|
535
|
+
|
|
536
|
+
The second half of the pair says whether the round trip completed, which is
|
|
537
|
+
the only thing the caller needs it for: whether to hand the per-``state``
|
|
538
|
+
budget back.
|
|
539
|
+
"""
|
|
540
|
+
|
|
541
|
+
resolution = await get_identity_resolution_cache().resolve(completion.identity, row, actor_ip=_client_host(request))
|
|
542
|
+
if not isinstance(resolution, ResolvedAccount):
|
|
543
|
+
# The resolver already wrote the ``login_failed reason=unknown_identity``
|
|
544
|
+
# row naming the provider, subject, e-mail and groups; this route adds
|
|
545
|
+
# nothing to it. The pending screen is the one place a failure is
|
|
546
|
+
# allowed to differ, because the person did authenticate.
|
|
547
|
+
return _pending_redirect(request, row, completion.identity), True
|
|
548
|
+
|
|
549
|
+
user = await DashboardUsersRepository(context.session).get_by_id(resolution.user_id)
|
|
550
|
+
if user is None: # pragma: no cover - resolved a moment ago
|
|
551
|
+
return await _failure(request, context, stage="account"), False
|
|
552
|
+
return await _issue_oidc_session(request, user), True
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
async def _acting_account(
|
|
556
|
+
request: Request,
|
|
557
|
+
context: DashboardAuthContext,
|
|
558
|
+
record: OidcFlowRecord,
|
|
559
|
+
*,
|
|
560
|
+
permission: Permission | None = None,
|
|
561
|
+
) -> tuple[DashboardPrincipal, DashboardUser] | None:
|
|
562
|
+
"""The signed-in account finishing this flow, when it is the one that started it.
|
|
563
|
+
|
|
564
|
+
A pre-flight and a re-authentication are things an account does to itself,
|
|
565
|
+
so the browser coming back must still be that account's, and must still
|
|
566
|
+
hold whatever the start required. Nothing else is accepted: not "some
|
|
567
|
+
admin", not "whoever holds the flow cookie".
|
|
568
|
+
|
|
569
|
+
The row is read fresh rather than from the five-second users cache, like
|
|
570
|
+
the body step-up endpoint does: what follows decides on the account's
|
|
571
|
+
factors and mints a cookie bound to its ``session_generation``, and neither
|
|
572
|
+
may be answered from a copy taken before the request.
|
|
573
|
+
"""
|
|
574
|
+
|
|
575
|
+
if record.acting_user_id is None:
|
|
576
|
+
return None
|
|
577
|
+
try:
|
|
578
|
+
principal = await validate_dashboard_session(request)
|
|
579
|
+
if permission is not None:
|
|
580
|
+
ensure_dashboard_permission(principal, permission)
|
|
581
|
+
except AppError:
|
|
582
|
+
return None
|
|
583
|
+
if principal.user_id is None or principal.user_id != record.acting_user_id:
|
|
584
|
+
return None
|
|
585
|
+
user = await DashboardUsersRepository(context.session).get_by_id(principal.user_id)
|
|
586
|
+
return None if user is None else (principal, user)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
async def _complete_test_login(
|
|
590
|
+
request: Request,
|
|
591
|
+
context: DashboardAuthContext,
|
|
592
|
+
row: DashboardAuthProvider,
|
|
593
|
+
record: OidcFlowRecord,
|
|
594
|
+
completion: OidcCompletion,
|
|
595
|
+
) -> tuple[RedirectResponse, bool]:
|
|
596
|
+
"""Record that this admin's browser completed a round trip through this configuration.
|
|
597
|
+
|
|
598
|
+
It provisions nothing, links nothing and issues no session: a pre-flight
|
|
599
|
+
that created an account would be a sign-in wearing a pre-flight's name, and
|
|
600
|
+
the identity it just verified may well be one this install ends up
|
|
601
|
+
refusing. All it leaves behind is the stamp the enable gate reads and an
|
|
602
|
+
audit row saying who proved what.
|
|
603
|
+
|
|
604
|
+
The stamp names a configuration, not just a moment: the callback has
|
|
605
|
+
already refused a flow whose provider was reconfigured since it started,
|
|
606
|
+
and the write below is conditional on the row still holding that same
|
|
607
|
+
sealed document, which closes the window between this callback's read and
|
|
608
|
+
its write.
|
|
609
|
+
"""
|
|
610
|
+
|
|
611
|
+
acting = await _acting_account(request, context, record, permission=Permission.SECURITY_WRITE)
|
|
612
|
+
if acting is None:
|
|
613
|
+
return await _failure(request, context, stage="acting_account"), False
|
|
614
|
+
principal, user = acting
|
|
615
|
+
# The sealed document as it stood when this callback read the row, taken
|
|
616
|
+
# before the stamping commit expires nothing else. The write is conditional
|
|
617
|
+
# on it: a configuration PATCH that lands while the code is being exchanged
|
|
618
|
+
# clears the proof, and an unconditional stamp would hand it straight back
|
|
619
|
+
# against an issuer nobody has tested.
|
|
620
|
+
verified_config = row.config_encrypted
|
|
621
|
+
stamped = await AuthProvidersRepository(context.session).record_test_login(
|
|
622
|
+
row.id, user_id=user.id, verified_at=utcnow(), verified_config=verified_config
|
|
623
|
+
)
|
|
624
|
+
if not stamped:
|
|
625
|
+
return await _failure(request, context, stage="provider"), False
|
|
626
|
+
AuditService.log_async(
|
|
627
|
+
"oidc_test_login_succeeded",
|
|
628
|
+
actor_ip=_client_host(request),
|
|
629
|
+
details={
|
|
630
|
+
"kind": row.kind,
|
|
631
|
+
"provider_key": row.provider_key,
|
|
632
|
+
"subject": completion.identity.subject,
|
|
633
|
+
},
|
|
634
|
+
# The acting admin as they are signed in right now -- usually a local
|
|
635
|
+
# password session. The round trip proved the *connection*, not a new
|
|
636
|
+
# way for this person to be here.
|
|
637
|
+
actor=AuditActor.from_principal(principal),
|
|
638
|
+
target=AuditTarget("auth_provider", row.id),
|
|
639
|
+
severity=AuditSeverity.WARNING,
|
|
640
|
+
)
|
|
641
|
+
return _redirect(OIDC_SETTINGS_PATH), True
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
async def _complete_step_up(
|
|
645
|
+
request: Request,
|
|
646
|
+
context: DashboardAuthContext,
|
|
647
|
+
row: DashboardAuthProvider,
|
|
648
|
+
record: OidcFlowRecord,
|
|
649
|
+
completion: OidcCompletion,
|
|
650
|
+
) -> tuple[RedirectResponse, bool]:
|
|
651
|
+
"""A fresh authentication at the identity provider, recorded as this account's step-up.
|
|
652
|
+
|
|
653
|
+
Three things have to hold, and each closes a different hole: the browser
|
|
654
|
+
belongs to the account that started the flow, the identity provider says
|
|
655
|
+
the person authenticated just now (``auth_time``, which is why the request
|
|
656
|
+
carried ``prompt=login`` and ``max_age=0``), and the subject it vouched for
|
|
657
|
+
is already linked to that very account. Without the last one, signing in at
|
|
658
|
+
the identity provider as anybody would step up for whoever's stolen cookie
|
|
659
|
+
started the flow.
|
|
660
|
+
"""
|
|
661
|
+
|
|
662
|
+
acting = await _acting_account(request, context, record)
|
|
663
|
+
if acting is None:
|
|
664
|
+
return await _failure(request, context, stage="acting_account"), False
|
|
665
|
+
principal, user = acting
|
|
666
|
+
if step_up_methods(user):
|
|
667
|
+
# The identity provider is an alternative to *nothing*. An account that
|
|
668
|
+
# holds a password or a TOTP secret presents that, here as everywhere
|
|
669
|
+
# else: otherwise a stolen cookie riding alongside a live provider
|
|
670
|
+
# session would step up without presenting anything new. This is the
|
|
671
|
+
# authority; the start route mints no proof and so needs no copy of it.
|
|
672
|
+
return await _failure(request, context, stage="factor"), False
|
|
673
|
+
if not _is_fresh_authentication(completion.auth_time):
|
|
674
|
+
return await _failure(request, context, stage="auth_time"), False
|
|
675
|
+
identity = await DashboardUsersRepository(context.session).get_identity(
|
|
676
|
+
completion.identity.provider, completion.identity.provider_key, completion.identity.subject
|
|
677
|
+
)
|
|
678
|
+
if identity is None or identity.user_id != user.id:
|
|
679
|
+
return await _failure(request, context, stage="identity"), False
|
|
680
|
+
|
|
681
|
+
verified_at = session_clock()
|
|
682
|
+
response = _redirect(OIDC_SETTINGS_PATH)
|
|
683
|
+
# The same two paths the body endpoint mints through -- the session
|
|
684
|
+
# cookie's own ``su`` claim, or the generation-bound step-up cookie -- so
|
|
685
|
+
# this flow adds no third way to hold a step-up.
|
|
686
|
+
await record_step_up_on(response, request, user, verified_at=verified_at)
|
|
687
|
+
AuditService.log_async(
|
|
688
|
+
"step_up_verified",
|
|
689
|
+
actor_ip=_client_host(request),
|
|
690
|
+
details={"username": user.username, "methods": ["oidc"], "provider_key": row.provider_key},
|
|
691
|
+
actor=AuditActor.from_principal(principal),
|
|
692
|
+
target=AuditTarget("user", user.id),
|
|
693
|
+
)
|
|
694
|
+
return response, True
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _is_fresh_authentication(auth_time: int | None) -> bool:
|
|
698
|
+
"""Whether the identity provider says the person authenticated inside the step-up window.
|
|
699
|
+
|
|
700
|
+
A missing ``auth_time`` is a refusal, not a pass: ``max_age=0`` was asked
|
|
701
|
+
for, and an identity provider that answers without saying when the person
|
|
702
|
+
authenticated has not told us what we asked. The tolerance is the same
|
|
703
|
+
clock skew the ID token is verified with -- stated, not implied.
|
|
704
|
+
"""
|
|
705
|
+
|
|
706
|
+
if auth_time is None:
|
|
707
|
+
return False
|
|
708
|
+
now = session_clock()
|
|
709
|
+
if auth_time > now + OIDC_CLOCK_SKEW_SECONDS:
|
|
710
|
+
return False
|
|
711
|
+
return now - auth_time <= STEP_UP_MAX_AGE_SECONDS + OIDC_CLOCK_SKEW_SECONDS
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _audit_actor(user: DashboardUser) -> AuditActor:
|
|
715
|
+
return AuditActor(
|
|
716
|
+
user_id=user.id,
|
|
717
|
+
username=user.username,
|
|
718
|
+
role_slug=user.role.slug,
|
|
719
|
+
auth_method=AuditAuthMethod.OIDC.value,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
async def _issue_oidc_session(request: Request, user: DashboardUser) -> RedirectResponse:
|
|
724
|
+
"""The same version-2 account session every other sign-in issues, capped at twelve hours.
|
|
725
|
+
|
|
726
|
+
``totp_verified`` is false because nothing here verified a local second
|
|
727
|
+
factor: an account that holds a TOTP secret under a policy that requires it
|
|
728
|
+
still has to present it. No step-up is stamped either -- signing in through
|
|
729
|
+
the identity provider is not consent to change security settings, and the
|
|
730
|
+
dedicated step-up flow is what mints that proof.
|
|
731
|
+
"""
|
|
732
|
+
|
|
733
|
+
session_id, ttl_seconds = await _create_user_session(
|
|
734
|
+
request,
|
|
735
|
+
user,
|
|
736
|
+
totp_verified=False,
|
|
737
|
+
auth_method=AuditAuthMethod.OIDC.value,
|
|
738
|
+
# The existing "this request is not obviously local" ceiling, reused
|
|
739
|
+
# rather than duplicated: a single sign-on session that outlives the
|
|
740
|
+
# identity provider's own session is a deprovisioning hole. A shorter
|
|
741
|
+
# configured dashboard lifetime still wins.
|
|
742
|
+
max_ttl_seconds=REMOTE_DASHBOARD_SESSION_TTL_SECONDS,
|
|
743
|
+
)
|
|
744
|
+
AuditService.log_async(
|
|
745
|
+
"login_success",
|
|
746
|
+
actor_ip=_client_host(request),
|
|
747
|
+
details={"method": AuditAuthMethod.OIDC.value, "username": user.username},
|
|
748
|
+
actor=_audit_actor(user),
|
|
749
|
+
target=AuditTarget("user", user.id),
|
|
750
|
+
)
|
|
751
|
+
response = _redirect(OIDC_DASHBOARD_PATH)
|
|
752
|
+
_set_session_cookie(response, session_id, request, max_age_seconds=ttl_seconds)
|
|
753
|
+
return response
|