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,838 @@
|
|
|
1
|
+
"""The OpenID Connect provider: Authorization Code with PKCE, and nothing else.
|
|
2
|
+
|
|
3
|
+
This is the second implementation of the :class:`AuthProvider` protocol. Like
|
|
4
|
+
the first it ends in the shared identity resolver: it turns a *verified* ID
|
|
5
|
+
token into an :class:`ExternalIdentity` and stops. It creates no account, links
|
|
6
|
+
nothing, re-evaluates nothing and writes none of the identity audit events the
|
|
7
|
+
resolver already writes.
|
|
8
|
+
|
|
9
|
+
Three rules carry most of the security weight here, and each is written as code
|
|
10
|
+
rather than left to a library's defaults:
|
|
11
|
+
|
|
12
|
+
* **The signing key is chosen before the algorithm.** The candidate key is
|
|
13
|
+
selected from the cached key set by ``kid``; the permitted algorithms are
|
|
14
|
+
derived from *that key's own type*, intersected with what the identity
|
|
15
|
+
provider advertises and with a fixed allow-list. The token header's ``alg``
|
|
16
|
+
is never an input, which is what makes ``alg: none`` and "verify this RS256
|
|
17
|
+
token as HS256 with the public key as the HMAC secret" impossible rather than
|
|
18
|
+
merely refused.
|
|
19
|
+
* **Key rotation is a non-event.** Keys are cached with the discovery document
|
|
20
|
+
for fifteen minutes; a token whose ``kid`` is unknown forces exactly one
|
|
21
|
+
refresh, behind a per-provider cooldown so forged ``kid`` values cannot turn
|
|
22
|
+
this endpoint into a request amplifier aimed at the identity provider.
|
|
23
|
+
* **The operator's issuer is a URL this server fetches**, so every fetch is
|
|
24
|
+
``https``, bounded in time and size, refuses redirects, and requires the
|
|
25
|
+
document's own ``issuer`` claim to equal the configured issuer exactly.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
import hmac
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import time
|
|
35
|
+
from base64 import b64encode
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import Any, Final
|
|
38
|
+
from urllib.parse import quote, urlencode
|
|
39
|
+
|
|
40
|
+
import aiohttp
|
|
41
|
+
import anyio
|
|
42
|
+
import jwt
|
|
43
|
+
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
|
|
44
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
|
45
|
+
from fastapi import Request
|
|
46
|
+
from jwt.algorithms import ECAlgorithm, RSAAlgorithm
|
|
47
|
+
|
|
48
|
+
from app.core.auth.providers import (
|
|
49
|
+
DEFAULT_PROVIDER_KEY,
|
|
50
|
+
MAX_DISPLAY_NAME_LENGTH,
|
|
51
|
+
MAX_SUBJECT_LENGTH,
|
|
52
|
+
ExternalIdentity,
|
|
53
|
+
)
|
|
54
|
+
from app.core.clients.http import lease_http_session
|
|
55
|
+
from app.db.models import AuthProviderKind, DashboardAuthProvider
|
|
56
|
+
from app.modules.auth_providers.config import OidcProviderConfig, oidc_config_fingerprint, validate_https_url
|
|
57
|
+
from app.modules.dashboard_users.repository import is_valid_email, normalize_email
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
#: How far apart this server's clock and the identity provider's may be before
|
|
62
|
+
#: a token is refused. A tolerance is not something an operator tunes
|
|
63
|
+
#: (PRINCIPLES P2, the ``OAUTH_TIMEOUT_SECONDS`` precedent), so it is a
|
|
64
|
+
#: constant: 60 s covers unsynchronised clocks without widening the window a
|
|
65
|
+
#: captured token is usable in, which the independent ``iat``-versus-flow rule
|
|
66
|
+
#: below bounds anyway.
|
|
67
|
+
OIDC_CLOCK_SKEW_SECONDS: Final[int] = 60
|
|
68
|
+
|
|
69
|
+
#: Every server-to-server fetch this module makes. The shared client session is
|
|
70
|
+
#: built with ``total=None``, so a call site that brings no timeout has none.
|
|
71
|
+
_FETCH_TIMEOUT_SECONDS: Final[float] = 10.0
|
|
72
|
+
_MAX_DOCUMENT_BYTES: Final[int] = 256 * 1024
|
|
73
|
+
_MAX_JWKS_KEYS: Final[int] = 20
|
|
74
|
+
_METADATA_TTL_SECONDS: Final[float] = 900.0
|
|
75
|
+
#: A ``kid`` this replica has never seen forces one refetch, then waits.
|
|
76
|
+
_REFRESH_COOLDOWN_SECONDS: Final[float] = 60.0
|
|
77
|
+
#: How long the last good key set keeps serving while refreshes fail. An
|
|
78
|
+
#: identity provider's five-minute outage must not lock everyone out; a key
|
|
79
|
+
#: revoked after a compromise must not keep validating forever.
|
|
80
|
+
_STALE_METADATA_MAX_AGE_SECONDS: Final[float] = 24 * 60 * 60
|
|
81
|
+
#: How long a *failed* refresh suppresses the next one while the cached key set
|
|
82
|
+
#: is still being served. Without it an identity provider whose discovery
|
|
83
|
+
#: endpoint times out while its token endpoint is healthy costs every start and
|
|
84
|
+
#: every callback another ten seconds, serialised behind the cache lock: the
|
|
85
|
+
#: outage of one endpoint becomes an outage of the whole sign-in path.
|
|
86
|
+
_FAILED_REFRESH_BACKOFF_SECONDS: Final[float] = 60.0
|
|
87
|
+
|
|
88
|
+
#: Asymmetric signature algorithms only. Every MAC algorithm and ``none`` is
|
|
89
|
+
#: absent by construction, not by a check that could be reordered away.
|
|
90
|
+
_SIGNING_ALGORITHMS: Final[frozenset[str]] = frozenset(
|
|
91
|
+
{"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"}
|
|
92
|
+
)
|
|
93
|
+
_RSA_ALGORITHMS: Final[tuple[str, ...]] = ("RS256", "RS384", "RS512", "PS256", "PS384", "PS512")
|
|
94
|
+
_EC_ALGORITHMS: Final[dict[str, tuple[str, ...]]] = {
|
|
95
|
+
"P-256": ("ES256",),
|
|
96
|
+
"P-384": ("ES384",),
|
|
97
|
+
"P-521": ("ES512",),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#: What the authorization request asks for. Group claims that need a scope are
|
|
101
|
+
#: configured at the identity provider against this client, not here: the
|
|
102
|
+
#: connection document names the claim, not the scope.
|
|
103
|
+
_SCOPE: Final[str] = "openid email profile"
|
|
104
|
+
|
|
105
|
+
__all__ = [
|
|
106
|
+
"OIDC_CLOCK_SKEW_SECONDS",
|
|
107
|
+
"OidcCompletion",
|
|
108
|
+
"OidcError",
|
|
109
|
+
"OidcExchangeError",
|
|
110
|
+
"OidcMetadata",
|
|
111
|
+
"OidcMetadataError",
|
|
112
|
+
"OidcProvider",
|
|
113
|
+
"OidcTokenError",
|
|
114
|
+
"get_oidc_provider",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class OidcError(Exception):
|
|
119
|
+
"""A sign-in that cannot continue. ``stage`` is the coarse audit label.
|
|
120
|
+
|
|
121
|
+
Never carries a value from the identity provider: the message is ours, the
|
|
122
|
+
stage names where we were, and neither reaches the browser.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
stage = "oidc"
|
|
126
|
+
|
|
127
|
+
def __init__(self, message: str, *, stage: str | None = None) -> None:
|
|
128
|
+
super().__init__(message)
|
|
129
|
+
if stage is not None:
|
|
130
|
+
self.stage = stage
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class OidcMetadataError(OidcError):
|
|
134
|
+
"""The discovery document or the key set could not be trusted."""
|
|
135
|
+
|
|
136
|
+
stage = "metadata"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class OidcExchangeError(OidcError):
|
|
140
|
+
"""The authorization code could not be exchanged."""
|
|
141
|
+
|
|
142
|
+
stage = "exchange"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class OidcTokenError(OidcError):
|
|
146
|
+
"""The ID token did not verify, or did not carry what this flow asked for."""
|
|
147
|
+
|
|
148
|
+
stage = "id_token"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True)
|
|
152
|
+
class OidcMetadata:
|
|
153
|
+
"""The discovery document and its key set, fetched and cached together."""
|
|
154
|
+
|
|
155
|
+
issuer: str
|
|
156
|
+
authorization_endpoint: str
|
|
157
|
+
token_endpoint: str
|
|
158
|
+
jwks_uri: str
|
|
159
|
+
signing_algorithms: tuple[str, ...]
|
|
160
|
+
token_auth_method: str
|
|
161
|
+
keys: tuple[dict[str, Any], ...]
|
|
162
|
+
|
|
163
|
+
def key_for(self, kid: str | None) -> dict[str, Any] | None:
|
|
164
|
+
"""The one candidate key, or ``None`` when the set does not name it.
|
|
165
|
+
|
|
166
|
+
A ``kid``-less token is only served when the set holds exactly one key;
|
|
167
|
+
guessing among several is how a retired key gets used.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
if kid is not None:
|
|
171
|
+
for key in self.keys:
|
|
172
|
+
if key.get("kid") == kid:
|
|
173
|
+
return key
|
|
174
|
+
return None
|
|
175
|
+
return self.keys[0] if len(self.keys) == 1 else None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True, slots=True)
|
|
179
|
+
class OidcCompletion:
|
|
180
|
+
"""What a completed round trip proved: an identity, and when it was proved.
|
|
181
|
+
|
|
182
|
+
``auth_time`` is the identity provider's own statement of when the person
|
|
183
|
+
last authenticated; the step-up flow requires it, ordinary sign-in ignores
|
|
184
|
+
it. The ID token itself never leaves this module.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
identity: ExternalIdentity
|
|
188
|
+
auth_time: int | None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(slots=True)
|
|
192
|
+
class _CacheEntry:
|
|
193
|
+
metadata: OidcMetadata
|
|
194
|
+
#: When the key set this entry holds was last fetched *successfully*. Both
|
|
195
|
+
#: the positive lifetime and the 24-hour trust ceiling are measured from it,
|
|
196
|
+
#: which is why a failed refresh must never move it.
|
|
197
|
+
fetched_at: float
|
|
198
|
+
forced_refresh_at: float
|
|
199
|
+
#: When a refresh last failed, or ``0.0`` since the last success. Held
|
|
200
|
+
#: apart from ``fetched_at`` so backing off a broken discovery endpoint
|
|
201
|
+
#: cannot extend how long keys of unknown age stay trusted.
|
|
202
|
+
failed_refresh_at: float = 0.0
|
|
203
|
+
|
|
204
|
+
def is_fresh(self, now: float) -> bool:
|
|
205
|
+
return now - self.fetched_at < _METADATA_TTL_SECONDS
|
|
206
|
+
|
|
207
|
+
def is_trusted(self, now: float) -> bool:
|
|
208
|
+
return now - self.fetched_at <= _STALE_METADATA_MAX_AGE_SECONDS
|
|
209
|
+
|
|
210
|
+
def is_backing_off(self, now: float) -> bool:
|
|
211
|
+
return self.failed_refresh_at > 0.0 and now - self.failed_refresh_at < _FAILED_REFRESH_BACKOFF_SECONDS
|
|
212
|
+
|
|
213
|
+
def serves(self, now: float) -> bool:
|
|
214
|
+
"""Whether this entry answers without attempting a fetch first.
|
|
215
|
+
|
|
216
|
+
Either it is inside its positive lifetime, or its last refresh failed
|
|
217
|
+
recently and it is still inside the trust ceiling -- in which case a
|
|
218
|
+
fetch would only queue another timeout behind the cache lock to reach
|
|
219
|
+
the same stale answer.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
return self.is_fresh(now) or (self.is_backing_off(now) and self.is_trusted(now))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# --- fetching -------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def _read_bounded(content: aiohttp.StreamReader) -> bytes:
|
|
229
|
+
"""Everything the response carries, up to one byte past the cap.
|
|
230
|
+
|
|
231
|
+
``StreamReader.read(n)`` answers with what has *arrived*, not with ``n``
|
|
232
|
+
bytes: a discovery document split across two network writes comes back
|
|
233
|
+
truncated from a single call and then fails to parse as JSON. Reading to
|
|
234
|
+
EOF is what makes the parse deterministic; the cap still bounds it, because
|
|
235
|
+
the loop stops as soon as it holds one byte more than the caller will
|
|
236
|
+
accept, and the request's own total timeout still bounds the wait.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
limit = _MAX_DOCUMENT_BYTES + 1
|
|
240
|
+
chunks: list[bytes] = []
|
|
241
|
+
received = 0
|
|
242
|
+
while received < limit:
|
|
243
|
+
chunk = await content.read(limit - received)
|
|
244
|
+
if not chunk:
|
|
245
|
+
break
|
|
246
|
+
chunks.append(chunk)
|
|
247
|
+
received += len(chunk)
|
|
248
|
+
return b"".join(chunks)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def _fetch_json(url: str, *, stage: str) -> dict[str, Any]:
|
|
252
|
+
"""One bounded, redirect-refusing, JSON-only GET against an operator URL."""
|
|
253
|
+
|
|
254
|
+
timeout = aiohttp.ClientTimeout(total=_FETCH_TIMEOUT_SECONDS)
|
|
255
|
+
try:
|
|
256
|
+
async with lease_http_session() as session:
|
|
257
|
+
async with session.get(
|
|
258
|
+
url,
|
|
259
|
+
allow_redirects=False,
|
|
260
|
+
timeout=timeout,
|
|
261
|
+
headers={"Accept": "application/json"},
|
|
262
|
+
) as response:
|
|
263
|
+
if response.status != 200:
|
|
264
|
+
# A redirect lands here too, deliberately: a redirect is how
|
|
265
|
+
# a host that passed validation becomes one that would not.
|
|
266
|
+
raise OidcMetadataError(f"fetch answered status {response.status}", stage=stage)
|
|
267
|
+
if not _is_json(response.content_type):
|
|
268
|
+
raise OidcMetadataError("fetch answered a non-JSON body", stage=stage)
|
|
269
|
+
body = await _read_bounded(response.content)
|
|
270
|
+
except OidcError:
|
|
271
|
+
raise
|
|
272
|
+
except Exception as exc: # network, TLS, timeout
|
|
273
|
+
raise OidcMetadataError("fetch failed", stage=stage) from exc
|
|
274
|
+
if len(body) > _MAX_DOCUMENT_BYTES:
|
|
275
|
+
raise OidcMetadataError("fetch answered an oversized body", stage=stage)
|
|
276
|
+
try:
|
|
277
|
+
document = json.loads(body)
|
|
278
|
+
except ValueError as exc:
|
|
279
|
+
raise OidcMetadataError("fetch answered invalid JSON", stage=stage) from exc
|
|
280
|
+
if not isinstance(document, dict):
|
|
281
|
+
raise OidcMetadataError("fetch answered a non-object body", stage=stage)
|
|
282
|
+
return document
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _is_json(content_type: str | None) -> bool:
|
|
286
|
+
value = (content_type or "").casefold()
|
|
287
|
+
return value == "application/json" or value.endswith("+json")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _endpoint(document: dict[str, Any], field: str) -> str:
|
|
291
|
+
raw = document.get(field)
|
|
292
|
+
if not isinstance(raw, str):
|
|
293
|
+
raise OidcMetadataError(f"discovery document has no {field}")
|
|
294
|
+
try:
|
|
295
|
+
# The advertised endpoints pass the same scheme and host rules as the
|
|
296
|
+
# issuer, but are NOT required to share its origin: Google Workspace
|
|
297
|
+
# publishes its keys on a different host from its issuer, and a rule
|
|
298
|
+
# operators must switch off for a target identity provider is not a
|
|
299
|
+
# rule. The ``issuer``-claim equality check below is what makes the
|
|
300
|
+
# document trustworthy enough to name them.
|
|
301
|
+
return validate_https_url(raw, field=field)
|
|
302
|
+
except Exception as exc:
|
|
303
|
+
raise OidcMetadataError(f"discovery document's {field} is not a usable URL") from exc
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
async def fetch_metadata(config: OidcProviderConfig) -> OidcMetadata:
|
|
307
|
+
document = await _fetch_json(config.discovery_url, stage="discovery")
|
|
308
|
+
if document.get("issuer") != config.issuer:
|
|
309
|
+
# OIDC Discovery mandates this equality; it is what binds the returned
|
|
310
|
+
# document to the issuer the operator typed.
|
|
311
|
+
raise OidcMetadataError("discovery document names another issuer")
|
|
312
|
+
# Every advertised endpoint is validated before any of them is used.
|
|
313
|
+
jwks_uri = _endpoint(document, "jwks_uri")
|
|
314
|
+
authorization_endpoint = _endpoint(document, "authorization_endpoint")
|
|
315
|
+
token_endpoint = _endpoint(document, "token_endpoint")
|
|
316
|
+
advertised = document.get("id_token_signing_alg_values_supported")
|
|
317
|
+
signing_algorithms = (
|
|
318
|
+
tuple(value for value in advertised if isinstance(value, str)) if isinstance(advertised, list) else ()
|
|
319
|
+
)
|
|
320
|
+
auth_methods = document.get("token_endpoint_auth_methods_supported")
|
|
321
|
+
token_auth_method = (
|
|
322
|
+
"client_secret_post"
|
|
323
|
+
if isinstance(auth_methods, list) and "client_secret_post" in auth_methods
|
|
324
|
+
else "client_secret_basic"
|
|
325
|
+
)
|
|
326
|
+
keys = _signing_keys(await _fetch_json(jwks_uri, stage="jwks"))
|
|
327
|
+
return OidcMetadata(
|
|
328
|
+
issuer=config.issuer,
|
|
329
|
+
authorization_endpoint=authorization_endpoint,
|
|
330
|
+
token_endpoint=token_endpoint,
|
|
331
|
+
jwks_uri=jwks_uri,
|
|
332
|
+
signing_algorithms=signing_algorithms,
|
|
333
|
+
token_auth_method=token_auth_method,
|
|
334
|
+
keys=keys,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _signing_keys(document: dict[str, Any]) -> tuple[dict[str, Any], ...]:
|
|
339
|
+
raw_keys = document.get("keys")
|
|
340
|
+
if not isinstance(raw_keys, list):
|
|
341
|
+
raise OidcMetadataError("key set has no keys", stage="jwks")
|
|
342
|
+
if len(raw_keys) > _MAX_JWKS_KEYS:
|
|
343
|
+
raise OidcMetadataError("key set is too large", stage="jwks")
|
|
344
|
+
keys = [key for key in raw_keys if isinstance(key, dict) and _is_verification_key(key)]
|
|
345
|
+
if not keys:
|
|
346
|
+
raise OidcMetadataError("key set holds no usable verification key", stage="jwks")
|
|
347
|
+
return tuple(keys)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _is_verification_key(key: dict[str, Any]) -> bool:
|
|
351
|
+
if key.get("kty") not in {"RSA", "EC"}:
|
|
352
|
+
return False
|
|
353
|
+
if key.get("use") not in {None, "sig"}:
|
|
354
|
+
return False
|
|
355
|
+
key_ops = key.get("key_ops")
|
|
356
|
+
if isinstance(key_ops, list) and "verify" not in key_ops:
|
|
357
|
+
return False
|
|
358
|
+
# A key set that ships private material is malformed; refusing it keeps a
|
|
359
|
+
# private exponent from ever reaching the JWK parser.
|
|
360
|
+
return "d" not in key
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# --- the cache ------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class OidcMetadataCache:
|
|
367
|
+
"""One entry per provider *configuration*, so a configuration write invalidates itself.
|
|
368
|
+
|
|
369
|
+
The key carries a digest of the connection document, not the row's
|
|
370
|
+
``updated_at``: that column has one-second resolution on SQLite, so two
|
|
371
|
+
writes inside the same second would share a key and a repointed provider
|
|
372
|
+
would keep serving the previous issuer's endpoints -- which is how the new
|
|
373
|
+
client secret ends up posted to the old identity provider's token endpoint,
|
|
374
|
+
a leg that runs *before* the issuer check could refuse the answer. A digest
|
|
375
|
+
of what the flow will actually use has no such window.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
def __init__(self) -> None:
|
|
379
|
+
self._entries: dict[tuple[str, str], _CacheEntry] = {}
|
|
380
|
+
self._lock = anyio.Lock()
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def _key(provider: DashboardAuthProvider, config: OidcProviderConfig) -> tuple[str, str]:
|
|
384
|
+
return (provider.id, oidc_config_fingerprint(config))
|
|
385
|
+
|
|
386
|
+
async def get(self, provider: DashboardAuthProvider, config: OidcProviderConfig) -> OidcMetadata:
|
|
387
|
+
key = self._key(provider, config)
|
|
388
|
+
now = time.monotonic()
|
|
389
|
+
entry = self._entries.get(key)
|
|
390
|
+
if entry is not None and entry.serves(now):
|
|
391
|
+
return entry.metadata
|
|
392
|
+
async with self._lock:
|
|
393
|
+
now = time.monotonic()
|
|
394
|
+
entry = self._entries.get(key)
|
|
395
|
+
if entry is not None and entry.serves(now):
|
|
396
|
+
return entry.metadata
|
|
397
|
+
return await self._fetch_locked(key, config, entry, now)
|
|
398
|
+
|
|
399
|
+
async def refresh_for_kid(
|
|
400
|
+
self, provider: DashboardAuthProvider, config: OidcProviderConfig, kid: str | None
|
|
401
|
+
) -> OidcMetadata | None:
|
|
402
|
+
"""One out-of-band refresh for an unknown ``kid``, or ``None`` while cooling down.
|
|
403
|
+
|
|
404
|
+
Without the cooldown, ID tokens carrying random ``kid`` values would be
|
|
405
|
+
a free request amplifier aimed at the identity provider; with it, a
|
|
406
|
+
published rotation still converges on the first token that uses the new
|
|
407
|
+
key.
|
|
408
|
+
"""
|
|
409
|
+
|
|
410
|
+
key = self._key(provider, config)
|
|
411
|
+
async with self._lock:
|
|
412
|
+
now = time.monotonic()
|
|
413
|
+
entry = self._entries.get(key)
|
|
414
|
+
if entry is not None:
|
|
415
|
+
# Whoever held the lock before this caller may have refreshed
|
|
416
|
+
# for the very same rotation. Answering from what is cached now
|
|
417
|
+
# -- rather than from the cooldown, which that refresh set --
|
|
418
|
+
# is the difference between two callbacks meeting a rotated key
|
|
419
|
+
# and only the first of them signing in.
|
|
420
|
+
cached = entry.metadata.key_for(kid)
|
|
421
|
+
if cached is not None:
|
|
422
|
+
return entry.metadata
|
|
423
|
+
if now - entry.forced_refresh_at < _REFRESH_COOLDOWN_SECONDS:
|
|
424
|
+
return None
|
|
425
|
+
entry.forced_refresh_at = now
|
|
426
|
+
try:
|
|
427
|
+
metadata = await self._fetch_locked(key, config, entry, now, forced=True)
|
|
428
|
+
except OidcError:
|
|
429
|
+
return None
|
|
430
|
+
return metadata if metadata.key_for(kid) is not None else None
|
|
431
|
+
|
|
432
|
+
async def _fetch_locked(
|
|
433
|
+
self,
|
|
434
|
+
key: tuple[str, str],
|
|
435
|
+
config: OidcProviderConfig,
|
|
436
|
+
entry: _CacheEntry | None,
|
|
437
|
+
now: float,
|
|
438
|
+
*,
|
|
439
|
+
forced: bool = False,
|
|
440
|
+
) -> OidcMetadata:
|
|
441
|
+
try:
|
|
442
|
+
metadata = await fetch_metadata(config)
|
|
443
|
+
except OidcError:
|
|
444
|
+
if entry is not None and entry.is_trusted(now):
|
|
445
|
+
# Serving the last good key set through a brief outage keeps
|
|
446
|
+
# this software from being less available than the identity
|
|
447
|
+
# provider it depends on. The cap is what keeps a key revoked
|
|
448
|
+
# after a compromise from being trusted indefinitely, and the
|
|
449
|
+
# backoff below is what keeps the outage from costing every
|
|
450
|
+
# request a fresh ten-second timeout behind this lock -- the
|
|
451
|
+
# stamp goes on its own field so the cap still counts from the
|
|
452
|
+
# last *successful* fetch.
|
|
453
|
+
entry.failed_refresh_at = now
|
|
454
|
+
logger.warning("OIDC metadata refresh failed; serving the cached key set")
|
|
455
|
+
return entry.metadata
|
|
456
|
+
raise
|
|
457
|
+
# One entry per provider, not per configuration: the key carries the
|
|
458
|
+
# document's digest so a write invalidates itself, and dropping the
|
|
459
|
+
# older entries of the same provider keeps a long-lived process from
|
|
460
|
+
# accumulating one entry per configuration edit it ever saw.
|
|
461
|
+
self._entries = {other: value for other, value in self._entries.items() if other[0] != key[0]}
|
|
462
|
+
self._entries[key] = _CacheEntry(
|
|
463
|
+
metadata=metadata,
|
|
464
|
+
fetched_at=now,
|
|
465
|
+
forced_refresh_at=now if forced else (entry.forced_refresh_at if entry is not None else 0.0),
|
|
466
|
+
)
|
|
467
|
+
return metadata
|
|
468
|
+
|
|
469
|
+
def clear(self) -> None:
|
|
470
|
+
self._entries.clear()
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
_metadata_cache = OidcMetadataCache()
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def get_oidc_metadata_cache() -> OidcMetadataCache:
|
|
477
|
+
return _metadata_cache
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# --- verification ---------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _permitted_algorithms(key: dict[str, Any], advertised: tuple[str, ...]) -> tuple[str, ...]:
|
|
484
|
+
"""What this key may have signed with -- derived from the key, never from the token."""
|
|
485
|
+
|
|
486
|
+
if key.get("kty") == "RSA":
|
|
487
|
+
family: tuple[str, ...] = _RSA_ALGORITHMS
|
|
488
|
+
elif key.get("kty") == "EC":
|
|
489
|
+
curve = key.get("crv")
|
|
490
|
+
family = _EC_ALGORITHMS.get(curve, ()) if isinstance(curve, str) else ()
|
|
491
|
+
else:
|
|
492
|
+
family = ()
|
|
493
|
+
declared = key.get("alg")
|
|
494
|
+
if isinstance(declared, str):
|
|
495
|
+
family = tuple(name for name in family if name == declared)
|
|
496
|
+
permitted = tuple(name for name in family if name in _SIGNING_ALGORITHMS)
|
|
497
|
+
if advertised:
|
|
498
|
+
permitted = tuple(name for name in permitted if name in advertised)
|
|
499
|
+
return permitted
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _public_key(key: dict[str, Any]) -> RSAPublicKey | EllipticCurvePublicKey:
|
|
503
|
+
serialized = json.dumps(key)
|
|
504
|
+
try:
|
|
505
|
+
if key.get("kty") == "RSA":
|
|
506
|
+
loaded = RSAAlgorithm.from_jwk(serialized)
|
|
507
|
+
else:
|
|
508
|
+
loaded = ECAlgorithm.from_jwk(serialized)
|
|
509
|
+
except Exception as exc:
|
|
510
|
+
raise OidcTokenError("signing key could not be parsed") from exc
|
|
511
|
+
if not isinstance(loaded, RSAPublicKey | EllipticCurvePublicKey):
|
|
512
|
+
raise OidcTokenError("signing key is not a public key")
|
|
513
|
+
return loaded
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def verify_id_token(
|
|
517
|
+
id_token: str,
|
|
518
|
+
*,
|
|
519
|
+
config: OidcProviderConfig,
|
|
520
|
+
metadata: OidcMetadata,
|
|
521
|
+
key: dict[str, Any],
|
|
522
|
+
nonce_hash: str,
|
|
523
|
+
flow_created_at: int,
|
|
524
|
+
now: int,
|
|
525
|
+
) -> dict[str, Any]:
|
|
526
|
+
"""The verified claims, or :class:`OidcTokenError`. Nothing else reads a claim."""
|
|
527
|
+
|
|
528
|
+
algorithms = _permitted_algorithms(key, metadata.signing_algorithms)
|
|
529
|
+
if not algorithms:
|
|
530
|
+
raise OidcTokenError("no permitted algorithm for the selected signing key")
|
|
531
|
+
try:
|
|
532
|
+
claims = jwt.decode(
|
|
533
|
+
id_token,
|
|
534
|
+
key=_public_key(key),
|
|
535
|
+
algorithms=list(algorithms),
|
|
536
|
+
audience=config.client_id,
|
|
537
|
+
issuer=config.issuer,
|
|
538
|
+
leeway=OIDC_CLOCK_SKEW_SECONDS,
|
|
539
|
+
options={"require": ["iss", "aud", "exp", "iat", "sub"]},
|
|
540
|
+
)
|
|
541
|
+
except OidcError:
|
|
542
|
+
raise
|
|
543
|
+
except Exception as exc:
|
|
544
|
+
raise OidcTokenError("ID token did not verify") from exc
|
|
545
|
+
|
|
546
|
+
audience = claims.get("aud")
|
|
547
|
+
if isinstance(audience, list) and len(audience) > 1 and claims.get("azp") != config.client_id:
|
|
548
|
+
raise OidcTokenError("ID token names several audiences without a matching azp")
|
|
549
|
+
|
|
550
|
+
issued_at = claims.get("iat")
|
|
551
|
+
if not isinstance(issued_at, int | float) or isinstance(issued_at, bool):
|
|
552
|
+
raise OidcTokenError("ID token has no usable iat")
|
|
553
|
+
if issued_at > now + OIDC_CLOCK_SKEW_SECONDS:
|
|
554
|
+
raise OidcTokenError("ID token was issued too far in the future")
|
|
555
|
+
# Independent of the skew tolerance: a token minted before this flow began
|
|
556
|
+
# is not the token this flow asked for, however distant its expiry. The
|
|
557
|
+
# flow lives ten minutes, so a token accepted here is minutes old.
|
|
558
|
+
if issued_at < flow_created_at - OIDC_CLOCK_SKEW_SECONDS:
|
|
559
|
+
raise OidcTokenError("ID token predates the flow it is answering")
|
|
560
|
+
|
|
561
|
+
nonce = claims.get("nonce")
|
|
562
|
+
if not isinstance(nonce, str) or not hmac.compare_digest(hash_flow_value(nonce), nonce_hash):
|
|
563
|
+
raise OidcTokenError("ID token carries the wrong nonce")
|
|
564
|
+
return claims
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def hash_flow_value(value: str) -> str:
|
|
568
|
+
"""The only form a ``state`` or a ``nonce`` is stored or rate-limited in."""
|
|
569
|
+
|
|
570
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# --- claims to identity ---------------------------------------------------
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _groups(raw: object) -> tuple[str, ...]:
|
|
577
|
+
values = [raw] if isinstance(raw, str) else raw if isinstance(raw, list) else []
|
|
578
|
+
groups: list[str] = []
|
|
579
|
+
for value in values:
|
|
580
|
+
if not isinstance(value, str):
|
|
581
|
+
continue
|
|
582
|
+
folded = value.strip().casefold()
|
|
583
|
+
if folded and folded not in groups:
|
|
584
|
+
groups.append(folded)
|
|
585
|
+
return tuple(groups)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _claims_email_verified(claims: dict[str, Any]) -> bool:
|
|
589
|
+
"""Whether the identity provider *affirmed* the address, for a claim that is present.
|
|
590
|
+
|
|
591
|
+
Only the JSON boolean ``true`` and the string ``"true"`` count. The string
|
|
592
|
+
spelling is not tolerance for sloppiness: Google's documented ID token
|
|
593
|
+
carries ``"email_verified": "true"``, Apple documents the claim as either a
|
|
594
|
+
string or a boolean, and Cognito emits strings for attributes it maps from
|
|
595
|
+
an upstream provider -- so an identity check that accepts only ``True``
|
|
596
|
+
would drop the address of every legitimately verified user at three of the
|
|
597
|
+
identity providers this feature exists for.
|
|
598
|
+
|
|
599
|
+
Everything else present is false, including ``"false"``, ``0``, ``null`` and
|
|
600
|
+
a container. This is the one claim in this module that used to fail open,
|
|
601
|
+
and the value it guards is the one the resolver links accounts by.
|
|
602
|
+
"""
|
|
603
|
+
|
|
604
|
+
value = claims.get("email_verified")
|
|
605
|
+
if isinstance(value, bool):
|
|
606
|
+
return value
|
|
607
|
+
return isinstance(value, str) and value.strip().casefold() == "true"
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def identity_from_claims(claims: dict[str, Any], config: OidcProviderConfig) -> ExternalIdentity | None:
|
|
611
|
+
"""``None`` when the token names nobody this system can hold as an identity."""
|
|
612
|
+
|
|
613
|
+
subject = claims.get(config.subject_claim)
|
|
614
|
+
# Verbatim: not case-folded (an OIDC ``sub`` is case-sensitive by
|
|
615
|
+
# specification, and folding would merge two distinct subjects at an
|
|
616
|
+
# identity provider that issues both spellings) and not trimmed either.
|
|
617
|
+
# Trimming is the same defect one character over: an identity provider that
|
|
618
|
+
# issues ``alice`` and ``<space>alice<space>`` as two subjects would have
|
|
619
|
+
# them collapse onto one identity here, and the second person would be
|
|
620
|
+
# signed into the first's account. Emptiness and length are *checked*
|
|
621
|
+
# against the stripped and the raw value; neither check rewrites the
|
|
622
|
+
# identifier that is looked up.
|
|
623
|
+
if not isinstance(subject, str) or not subject.strip() or len(subject) > MAX_SUBJECT_LENGTH:
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
email: str | None = None
|
|
627
|
+
# ``email_verified`` present and not affirmative means the identity provider
|
|
628
|
+
# is telling us the address is self-asserted. The resolver would use it for
|
|
629
|
+
# e-mail linking, for the new account's address and for ``email_domain``
|
|
630
|
+
# role rules -- the last of which is a role escalation. Absent is not false:
|
|
631
|
+
# many providers omit the claim, and linking is off by default.
|
|
632
|
+
if "email_verified" not in claims or _claims_email_verified(claims):
|
|
633
|
+
candidate = claims.get(config.email_claim)
|
|
634
|
+
if isinstance(candidate, str):
|
|
635
|
+
normalized = normalize_email(candidate)
|
|
636
|
+
email = normalized if normalized is not None and is_valid_email(normalized) else None
|
|
637
|
+
|
|
638
|
+
raw_name = claims.get(config.name_claim)
|
|
639
|
+
display_name = (
|
|
640
|
+
raw_name.strip()[:MAX_DISPLAY_NAME_LENGTH] if isinstance(raw_name, str) and raw_name.strip() else None
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
return ExternalIdentity(
|
|
644
|
+
provider=AuthProviderKind.OIDC.value,
|
|
645
|
+
provider_key=DEFAULT_PROVIDER_KEY,
|
|
646
|
+
subject=subject,
|
|
647
|
+
email=email,
|
|
648
|
+
display_name=display_name,
|
|
649
|
+
groups=_groups(claims.get(config.groups_claim)),
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# --- the provider ---------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
class OidcProvider:
|
|
657
|
+
"""Redirect-style sign-in at an operator-configured identity provider.
|
|
658
|
+
|
|
659
|
+
``resolve_identity`` returns nothing because a bare request carries no
|
|
660
|
+
evidence this provider recognises; the round trip is ``begin_login`` (the
|
|
661
|
+
authorization URL) and ``complete_login`` (the code exchange and the ID
|
|
662
|
+
token), both driven by the public routes in ``dashboard_auth``.
|
|
663
|
+
"""
|
|
664
|
+
|
|
665
|
+
kind = AuthProviderKind.OIDC
|
|
666
|
+
provider_key = DEFAULT_PROVIDER_KEY
|
|
667
|
+
|
|
668
|
+
def resolve_identity(self, request: Request) -> ExternalIdentity | None:
|
|
669
|
+
del request
|
|
670
|
+
return None
|
|
671
|
+
|
|
672
|
+
async def begin_login(
|
|
673
|
+
self,
|
|
674
|
+
*,
|
|
675
|
+
provider: DashboardAuthProvider,
|
|
676
|
+
config: OidcProviderConfig,
|
|
677
|
+
state: str,
|
|
678
|
+
nonce: str,
|
|
679
|
+
code_challenge: str,
|
|
680
|
+
prompt_login: bool = False,
|
|
681
|
+
) -> str:
|
|
682
|
+
"""The authorization URL to send the browser to. PKCE is always S256."""
|
|
683
|
+
|
|
684
|
+
metadata = await get_oidc_metadata_cache().get(provider, config)
|
|
685
|
+
parameters = [
|
|
686
|
+
("response_type", "code"),
|
|
687
|
+
("client_id", config.client_id),
|
|
688
|
+
("redirect_uri", config.redirect_uri),
|
|
689
|
+
("scope", _SCOPE),
|
|
690
|
+
("state", state),
|
|
691
|
+
("nonce", nonce),
|
|
692
|
+
("code_challenge", code_challenge),
|
|
693
|
+
("code_challenge_method", "S256"),
|
|
694
|
+
]
|
|
695
|
+
if prompt_login:
|
|
696
|
+
# Re-authentication as a step-up factor: ask for a fresh sign-in and
|
|
697
|
+
# for the identity provider to say when it happened.
|
|
698
|
+
parameters += [("prompt", "login"), ("max_age", "0")]
|
|
699
|
+
separator = "&" if "?" in metadata.authorization_endpoint else "?"
|
|
700
|
+
return metadata.authorization_endpoint + separator + urlencode(parameters)
|
|
701
|
+
|
|
702
|
+
async def complete_login(
|
|
703
|
+
self,
|
|
704
|
+
*,
|
|
705
|
+
provider: DashboardAuthProvider,
|
|
706
|
+
config: OidcProviderConfig,
|
|
707
|
+
code: str,
|
|
708
|
+
code_verifier: str,
|
|
709
|
+
redirect_uri: str,
|
|
710
|
+
nonce_hash: str,
|
|
711
|
+
flow_created_at: int,
|
|
712
|
+
now: int | None = None,
|
|
713
|
+
) -> OidcCompletion:
|
|
714
|
+
"""Exchange the code once, verify the ID token, and build the identity."""
|
|
715
|
+
|
|
716
|
+
metadata = await get_oidc_metadata_cache().get(provider, config)
|
|
717
|
+
id_token = await self._exchange(
|
|
718
|
+
config=config,
|
|
719
|
+
metadata=metadata,
|
|
720
|
+
code=code,
|
|
721
|
+
code_verifier=code_verifier,
|
|
722
|
+
redirect_uri=redirect_uri,
|
|
723
|
+
)
|
|
724
|
+
key, metadata = await self._select_key(provider, config, metadata, id_token)
|
|
725
|
+
claims = verify_id_token(
|
|
726
|
+
id_token,
|
|
727
|
+
config=config,
|
|
728
|
+
metadata=metadata,
|
|
729
|
+
key=key,
|
|
730
|
+
nonce_hash=nonce_hash,
|
|
731
|
+
flow_created_at=flow_created_at,
|
|
732
|
+
now=int(time.time()) if now is None else now,
|
|
733
|
+
)
|
|
734
|
+
identity = identity_from_claims(claims, config)
|
|
735
|
+
if identity is None:
|
|
736
|
+
raise OidcTokenError("ID token names no usable subject")
|
|
737
|
+
auth_time = claims.get("auth_time")
|
|
738
|
+
return OidcCompletion(
|
|
739
|
+
identity=identity,
|
|
740
|
+
auth_time=int(auth_time)
|
|
741
|
+
if isinstance(auth_time, int | float) and not isinstance(auth_time, bool)
|
|
742
|
+
else None,
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
async def _select_key(
|
|
746
|
+
self,
|
|
747
|
+
provider: DashboardAuthProvider,
|
|
748
|
+
config: OidcProviderConfig,
|
|
749
|
+
metadata: OidcMetadata,
|
|
750
|
+
id_token: str,
|
|
751
|
+
) -> tuple[dict[str, Any], OidcMetadata]:
|
|
752
|
+
try:
|
|
753
|
+
header = jwt.get_unverified_header(id_token)
|
|
754
|
+
except Exception as exc:
|
|
755
|
+
raise OidcTokenError("ID token header could not be read") from exc
|
|
756
|
+
raw_kid = header.get("kid")
|
|
757
|
+
kid = raw_kid if isinstance(raw_kid, str) else None
|
|
758
|
+
key = metadata.key_for(kid)
|
|
759
|
+
if key is None:
|
|
760
|
+
refreshed = await get_oidc_metadata_cache().refresh_for_kid(provider, config, kid)
|
|
761
|
+
if refreshed is None:
|
|
762
|
+
raise OidcTokenError("ID token names an unknown signing key")
|
|
763
|
+
metadata = refreshed
|
|
764
|
+
key = metadata.key_for(kid)
|
|
765
|
+
if key is None: # pragma: no cover - refresh_for_kid already checked
|
|
766
|
+
raise OidcTokenError("ID token names an unknown signing key")
|
|
767
|
+
return key, metadata
|
|
768
|
+
|
|
769
|
+
async def _exchange(
|
|
770
|
+
self,
|
|
771
|
+
*,
|
|
772
|
+
config: OidcProviderConfig,
|
|
773
|
+
metadata: OidcMetadata,
|
|
774
|
+
code: str,
|
|
775
|
+
code_verifier: str,
|
|
776
|
+
redirect_uri: str,
|
|
777
|
+
) -> str:
|
|
778
|
+
"""One token request, on the non-retrying session.
|
|
779
|
+
|
|
780
|
+
The flow row is already consumed by the time this runs, so a replayed
|
|
781
|
+
authorization code never reaches the token endpoint twice through this
|
|
782
|
+
system -- and for the same reason this request is never retried.
|
|
783
|
+
"""
|
|
784
|
+
|
|
785
|
+
form = {
|
|
786
|
+
"grant_type": "authorization_code",
|
|
787
|
+
"code": code,
|
|
788
|
+
"redirect_uri": redirect_uri,
|
|
789
|
+
"client_id": config.client_id,
|
|
790
|
+
"code_verifier": code_verifier,
|
|
791
|
+
}
|
|
792
|
+
headers = {"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"}
|
|
793
|
+
if metadata.token_auth_method == "client_secret_post":
|
|
794
|
+
form["client_secret"] = config.client_secret
|
|
795
|
+
else:
|
|
796
|
+
# RFC 6749 §2.3.1: both halves are form-urlencoded before they are
|
|
797
|
+
# joined and base64ed. Assembled from a list so no line of this
|
|
798
|
+
# file ever reads as a literal credential pair.
|
|
799
|
+
parts = [quote(config.client_id, safe=""), quote(config.client_secret, safe="")]
|
|
800
|
+
headers["Authorization"] = "Basic " + b64encode(":".join(parts).encode()).decode("ascii")
|
|
801
|
+
timeout = aiohttp.ClientTimeout(total=_FETCH_TIMEOUT_SECONDS)
|
|
802
|
+
try:
|
|
803
|
+
async with lease_http_session() as session:
|
|
804
|
+
async with session.post(
|
|
805
|
+
metadata.token_endpoint,
|
|
806
|
+
data=form,
|
|
807
|
+
headers=headers,
|
|
808
|
+
allow_redirects=False,
|
|
809
|
+
timeout=timeout,
|
|
810
|
+
) as response:
|
|
811
|
+
status = response.status
|
|
812
|
+
# Read to EOF, not "whatever arrived": a token response
|
|
813
|
+
# split across two writes would otherwise fail to parse and
|
|
814
|
+
# burn an authorization code that was perfectly good, and
|
|
815
|
+
# the code cannot be presented twice.
|
|
816
|
+
body = await _read_bounded(response.content)
|
|
817
|
+
except Exception as exc:
|
|
818
|
+
raise OidcExchangeError("token request failed") from exc
|
|
819
|
+
if status != 200 or len(body) > _MAX_DOCUMENT_BYTES:
|
|
820
|
+
# The identity provider's ``error_description`` is deliberately not
|
|
821
|
+
# read: nothing from it reaches a response, a log line or an audit
|
|
822
|
+
# row, and neither does the request body that carried the secret.
|
|
823
|
+
raise OidcExchangeError("token request was refused")
|
|
824
|
+
try:
|
|
825
|
+
payload = json.loads(body)
|
|
826
|
+
except ValueError as exc:
|
|
827
|
+
raise OidcExchangeError("token response was not JSON") from exc
|
|
828
|
+
id_token = payload.get("id_token") if isinstance(payload, dict) else None
|
|
829
|
+
if not isinstance(id_token, str) or not id_token:
|
|
830
|
+
raise OidcExchangeError("token response carried no ID token")
|
|
831
|
+
return id_token
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
_provider = OidcProvider()
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def get_oidc_provider() -> OidcProvider:
|
|
838
|
+
return _provider
|