Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions ciris_engine/logic/adapters/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
import logging
import os
import secrets
import hmac
import time
import base64
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Annotated, Any, Dict, List, Optional, Set, cast
Expand Down Expand Up @@ -59,6 +63,56 @@
# Module-level flag to prevent multiple attestation triggers from the endpoint
_attestation_triggered_from_endpoint = False

# Module-level secret for HMAC signing of OAuth state
_OAUTH_STATE_SECRET = os.getenv("CIRIS_OAUTH_STATE_SECRET") or os.getenv("JWT_SECRET_KEY")
if not _OAUTH_STATE_SECRET:
raise RuntimeError(
"Missing required environment variable for OAuth state security. "
"You must set CIRIS_OAUTH_STATE_SECRET or JWT_SECRET_KEY to a secure random string."
)

def _sign_oauth_state(state_data: dict) -> str:
"""Sign OAuth state parameter with HMAC."""
# Add timestamp for expiration
state_data["ts"] = int(time.time())

# Generate HMAC signature
state_json = json.dumps(state_data, sort_keys=True)
signature = hmac.new(_OAUTH_STATE_SECRET.encode(), state_json.encode(), "sha256").hexdigest()
state_data["sig"] = signature

return base64.urlsafe_b64encode(json.dumps(state_data).encode()).decode()

def _verify_oauth_state(state: str) -> dict:
"""Verify and decode OAuth state parameter."""
try:
state_data = json.loads(base64.urlsafe_b64decode(state.encode()).decode())
signature = state_data.pop("sig", None)

if not signature:
raise ValueError("Missing signature")

# Verify timestamp (prevent replay attacks)
timestamp = state_data.get("ts", 0)
if time.time() - timestamp > 600: # 10 minute expiration
raise ValueError("State expired")

# Verify HMAC
expected_sig = hmac.new(
_OAUTH_STATE_SECRET.encode(),
json.dumps(state_data, sort_keys=True).encode(),
"sha256"
).hexdigest()

if not hmac.compare_digest(signature, expected_sig):
raise ValueError("Invalid signature")

return state_data

except Exception as e:
logger.warning(f"State verification failed: {e}")
raise ValueError(f"Invalid state parameter: {e}")

# OAuth Frontend Redirect Configuration
# These environment variables control where users are redirected after OAuth and what parameters are included
OAUTH_FRONTEND_URL = os.getenv("OAUTH_FRONTEND_URL") # e.g., https://scout.ciris.ai
Expand Down Expand Up @@ -155,7 +209,7 @@
if not is_private:
logger.warning("Rejected HTTP redirect_uri to public host")
return False
logger.debug(f"Allowing HTTP redirect to private network: {netloc}")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
return True
if scheme == "https":
return True
Expand Down Expand Up @@ -185,8 +239,8 @@
return True
return any(redirect_domain.endswith("." + allowed) for allowed in allowed_domains)


def validate_redirect_uri(redirect_uri: Optional[str]) -> Optional[str]:

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs sensitive data (password) as clear text.
"""
Validate redirect_uri to prevent open redirect attacks.

Expand Down Expand Up @@ -229,7 +283,7 @@

# Private network hosts are always allowed (Home Assistant, local dev)
if is_private:
logger.debug(f"Allowing redirect to private network host: {redirect_hostname}")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
return redirect_uri

# Check against allowed domains for public URLs (hostname only, no port)
Expand Down Expand Up @@ -628,8 +682,8 @@
state_data["redirect_uri"] = validated_redirect_uri
logger.info("OAuth login initiated with validated redirect_uri")

# Base64 encode the state JSON
state = base64.urlsafe_b64encode(json.dumps(state_data).encode()).decode()
# Sign the state JSON with HMAC
state = _sign_oauth_state(state_data)

# Use OAUTH_CALLBACK_BASE_URL environment variable, or construct from request
base_url = os.getenv("OAUTH_CALLBACK_BASE_URL")
Expand Down Expand Up @@ -1285,7 +1339,7 @@
query_string = urllib.parse.urlencode(merged_params)
redirect_url = f"{base_redirect_uri}?{query_string}"
logger.info(
f"Redirecting OAuth user to provided redirect_uri with {len(existing_params)} existing params: {base_redirect_uri}"

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
)
elif OAUTH_FRONTEND_URL:
# Use configured frontend URL
Expand Down Expand Up @@ -1396,12 +1450,14 @@
marketing_opt_in_from_uri = None

try:
state_json = base64.urlsafe_b64decode(state.encode()).decode()
state_data = json.loads(state_json)
state_data = _verify_oauth_state(state)
redirect_uri = state_data.get("redirect_uri")
except Exception as e:
logger.error(f"State verification failed: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid state parameter")

try:
# Defense-in-depth: Re-validate redirect_uri even from state
# (state could theoretically be tampered with)
redirect_uri = validate_redirect_uri(redirect_uri)

# Extract marketing_opt_in from redirect_uri query parameters
Expand All @@ -1413,10 +1469,9 @@
elif marketing_opt_in_str in ("false", "0", "no"):
marketing_opt_in_from_uri = False

logger.debug(f"Decoded state: redirect_uri={redirect_uri}, marketing_opt_in={marketing_opt_in_from_uri}")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
except Exception as e:
# If state decode fails, log but continue (backward compatibility)
logger.warning(f"Failed to decode state parameter: {e}. Using default redirect.")
logger.warning(f"Failed to process redirect_uri from state: {e}. Using default redirect.")

# Use marketing_opt_in from redirect_uri if available, otherwise use query param
final_marketing_opt_in = (
Expand Down
39 changes: 35 additions & 4 deletions ciris_engine/logic/buses/prohibitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,22 @@ class ProhibitionSeverity(str, Enum):
"health",
"healthcare",
"health_care",
"wellbeing",
"wellness",

# Physiological/biological terms
"physiological",
"biological_monitoring",
"biosensing",
"biometric",
"biometric_analysis",

# Care management
"care_coordination",
"care_management",
"emergency_assessment",

# Euphemisms
"preventive_care",
"integrative_care",
# Clinical terms with variants
"clinical",
"clinician",
Expand Down Expand Up @@ -139,8 +153,6 @@ class ProhibitionSeverity(str, Enum):
"lab_results",
"lab_results_interpretation",
"test_results",
"vital_signs",
"vitals",
# Care types
"patient_care",
"medical_history",
Expand Down Expand Up @@ -996,6 +1008,25 @@ class ProhibitionSeverity(str, Enum):
# These are explicitly allowed for all agents

STANDARD_OPERATIONS = {
# General wellness & fitness (FDA Low Risk exceptions)
"wellness",
"wellbeing",
"well-being",
"fitness",
"vitals",
"vital_signs",
"vital_monitoring",
"vital_statistics",
"life_signs",
"activity_tracking",
"heart_rate",
"pulse",
"sleep_tracking",
"calorie",
"nutrition_tracking",
"status_monitoring",
"lifestyle_medicine",

"data_collection", # Basic telemetry and usage
"survey_design", # User feedback collection
"focus_groups", # User research
Expand Down
2 changes: 1 addition & 1 deletion client/androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CIRIS"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31">

<!-- Main Activity (Compose) -->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">local</domain>
</domain-config>
</network-security-config>
Loading