From 2dce2993cc1eb1d3967fefa4d6a69f13c081ec2f Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 11 Mar 2026 12:24:16 -0700 Subject: [PATCH] Fix AWS SSO for Bedrock profile auth in containers SSO login was broken in containers due to three issues: the sso_session indirection format not being resolved by Claude Code's AWS SDK, SSO detection only checking sso_start_url (missing sso_session), and the OAuth callback port not being accessible from inside the container. This fix runs SSO login on the host OS (where the browser and ports work natively) by having the container emit a marker that the Tauri app detects in terminal output, triggering host-side `aws sso login`. The entrypoint also inlines sso_session properties into profile sections and injects awsAuthRefresh into Claude Code config for mid-session refresh. Co-Authored-By: Claude Opus 4.6 --- app/src-tauri/src/commands/aws_commands.rs | 30 +++++++++++++ app/src-tauri/src/commands/mod.rs | 1 + .../src/commands/terminal_commands.rs | 9 ++-- app/src-tauri/src/docker/container.rs | 1 + app/src-tauri/src/lib.rs | 2 + app/src/components/terminal/TerminalView.tsx | 30 +++++++++++++ app/src/lib/tauri-commands.ts | 4 ++ container/Dockerfile | 3 ++ container/entrypoint.sh | 43 +++++++++++++++++++ container/triple-c-sso-refresh | 33 ++++++++++++++ 10 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 app/src-tauri/src/commands/aws_commands.rs create mode 100755 container/triple-c-sso-refresh diff --git a/app/src-tauri/src/commands/aws_commands.rs b/app/src-tauri/src/commands/aws_commands.rs new file mode 100644 index 0000000..c1b8db7 --- /dev/null +++ b/app/src-tauri/src/commands/aws_commands.rs @@ -0,0 +1,30 @@ +use tauri::State; +use crate::AppState; + +#[tauri::command] +pub async fn aws_sso_refresh( + project_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let project = state.projects_store.get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + let profile = project.bedrock_config.as_ref() + .and_then(|b| b.aws_profile.clone()) + .or_else(|| state.settings_store.get().global_aws.aws_profile.clone()) + .unwrap_or_else(|| "default".to_string()); + + log::info!("Running host-side AWS SSO login for profile '{}'", profile); + + let status = tokio::process::Command::new("aws") + .args(["sso", "login", "--profile", &profile]) + .status() + .await + .map_err(|e| format!("Failed to run aws sso login: {}", e))?; + + if !status.success() { + return Err("SSO login failed or was cancelled".to_string()); + } + + Ok(()) +} diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 6f0d0bd..92a906f 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod aws_commands; pub mod docker_commands; pub mod file_commands; pub mod mcp_commands; diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index aede054..f0702af 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -40,11 +40,12 @@ if aws sts get-caller-identity --profile '{profile}' >/dev/null 2>&1; then echo "AWS session valid." else echo "AWS session expired or invalid." - # Check if this profile uses SSO (has sso_start_url configured) - if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1; then - echo "Starting SSO login — click the URL below to authenticate:" + # Check if this profile uses SSO (has sso_start_url or sso_session configured) + if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1 || \ + aws configure get sso_session --profile '{profile}' >/dev/null 2>&1; then + echo "Starting SSO login..." echo "" - aws sso login --profile '{profile}' + triple-c-sso-refresh if [ $? -ne 0 ]; then echo "" echo "SSO login failed or was cancelled. Starting Claude anyway..." diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 4e09342..7d2a41d 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -459,6 +459,7 @@ pub async fn create_container( if let Some(p) = profile { env_vars.push(format!("AWS_PROFILE={}", p)); } + env_vars.push("AWS_SSO_AUTH_REFRESH_CMD=triple-c-sso-refresh".to_string()); } BedrockAuthMethod::BearerToken => { if let Some(ref token) = bedrock.aws_bearer_token { diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index e98d66c..f84dbd4 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -114,6 +114,8 @@ pub fn run() { commands::mcp_commands::add_mcp_server, commands::mcp_commands::update_mcp_server, commands::mcp_commands::remove_mcp_server, + // AWS + commands::aws_commands::aws_sso_refresh, // Updates commands::update_commands::get_app_version, commands::update_commands::check_for_updates, diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 2c1e29d..22258a7 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -6,6 +6,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import { openUrl } from "@tauri-apps/plugin-opener"; import "@xterm/xterm/css/xterm.css"; import { useTerminal } from "../../hooks/useTerminal"; +import { useAppState } from "../../store/appState"; +import { awsSsoRefresh } from "../../lib/tauri-commands"; import { UrlDetector } from "../../lib/urlDetector"; import UrlToast from "./UrlToast"; @@ -23,6 +25,12 @@ export default function TerminalView({ sessionId, active }: Props) { const detectorRef = useRef(null); const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal(); + const ssoBufferRef = useRef(""); + const ssoTriggeredRef = useRef(false); + const projectId = useAppState( + (s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId + ); + const [detectedUrl, setDetectedUrl] = useState(null); const [imagePasteMsg, setImagePasteMsg] = useState(null); const [isAtBottom, setIsAtBottom] = useState(true); @@ -152,10 +160,30 @@ export default function TerminalView({ sessionId, active }: Props) { const detector = new UrlDetector((url) => setDetectedUrl(url)); detectorRef.current = detector; + const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###"; + const textDecoder = new TextDecoder(); + const outputPromise = onOutput(sessionId, (data) => { if (aborted) return; term.write(data); detector.feed(data); + + // Scan for SSO refresh marker in terminal output + if (!ssoTriggeredRef.current && projectId) { + const text = textDecoder.decode(data, { stream: true }); + // Combine with overlap from previous chunk to handle marker spanning chunks + const combined = ssoBufferRef.current + text; + if (combined.includes(SSO_MARKER)) { + ssoTriggeredRef.current = true; + ssoBufferRef.current = ""; + awsSsoRefresh(projectId).catch((e) => + console.error("AWS SSO refresh failed:", e) + ); + } else { + // Keep last N chars as overlap for next chunk + ssoBufferRef.current = combined.slice(-SSO_MARKER.length); + } + } }).then((unlisten) => { if (aborted) unlisten(); return unlisten; @@ -189,6 +217,8 @@ export default function TerminalView({ sessionId, active }: Props) { aborted = true; detector.dispose(); detectorRef.current = null; + ssoTriggeredRef.current = false; + ssoBufferRef.current = ""; osc52Disposable.dispose(); inputDisposable.dispose(); scrollDisposable.dispose(); diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 9671477..eaa5809 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -40,6 +40,10 @@ export const listAwsProfiles = () => export const detectHostTimezone = () => invoke("detect_host_timezone"); +// AWS +export const awsSsoRefresh = (projectId: string) => + invoke("aws_sso_refresh", { projectId }); + // Terminal export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string) => invoke("open_terminal_session", { projectId, sessionId, sessionType }); diff --git a/container/Dockerfile b/container/Dockerfile index cf873e8..227df80 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -119,6 +119,9 @@ RUN chmod +x /usr/local/bin/audio-shim \ && ln -sf /usr/local/bin/audio-shim /usr/local/bin/rec \ && ln -sf /usr/local/bin/audio-shim /usr/local/bin/arecord +COPY triple-c-sso-refresh /usr/local/bin/triple-c-sso-refresh +RUN chmod +x /usr/local/bin/triple-c-sso-refresh + COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler diff --git a/container/entrypoint.sh b/container/entrypoint.sh index acb7a20..4ca8e18 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -84,6 +84,31 @@ if [ -d /tmp/.host-aws ]; then # Ensure writable cache directories exist mkdir -p /home/claude/.aws/sso/cache /home/claude/.aws/cli/cache chown -R claude:claude /home/claude/.aws/sso /home/claude/.aws/cli + + # Inline sso_session properties into profile sections so AWS SDKs that don't + # support the sso_session indirection format can resolve sso_region, etc. + if [ -f /home/claude/.aws/config ]; then + python3 -c ' +import configparser, sys +c = configparser.ConfigParser() +c.read(sys.argv[1]) +for sec in c.sections(): + if not sec.startswith("profile ") and sec != "default": + continue + session = c.get(sec, "sso_session", fallback=None) + if not session or c.has_option(sec, "sso_start_url"): + continue + ss = f"sso-session {session}" + if not c.has_section(ss): + continue + for key in ("sso_start_url", "sso_region", "sso_registration_scopes"): + val = c.get(ss, key, fallback=None) + if val: + c.set(sec, key, val) +with open(sys.argv[1], "w") as f: + c.write(f) +' /home/claude/.aws/config 2>/dev/null || true + fi fi # ── Git credential helper (for HTTPS token) ───────────────────────────────── @@ -164,6 +189,24 @@ if [ -n "$MCP_SERVERS_JSON" ]; then unset MCP_SERVERS_JSON fi +# ── AWS SSO auth refresh command ────────────────────────────────────────────── +# When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls +# triple-c-sso-refresh when AWS credentials expire mid-session. +if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then + CLAUDE_JSON="/home/claude/.claude.json" + if [ -f "$CLAUDE_JSON" ]; then + MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null) + if [ -n "$MERGED" ]; then + printf '%s\n' "$MERGED" > "$CLAUDE_JSON" + fi + else + printf '{"awsAuthRefresh":"%s"}\n' "$AWS_SSO_AUTH_REFRESH_CMD" > "$CLAUDE_JSON" + fi + chown claude:claude "$CLAUDE_JSON" + chmod 600 "$CLAUDE_JSON" + unset AWS_SSO_AUTH_REFRESH_CMD +fi + # ── Docker socket permissions ──────────────────────────────────────────────── if [ -S /var/run/docker.sock ]; then DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) diff --git a/container/triple-c-sso-refresh b/container/triple-c-sso-refresh new file mode 100755 index 0000000..530b53d --- /dev/null +++ b/container/triple-c-sso-refresh @@ -0,0 +1,33 @@ +#!/bin/bash +# Signal Triple-C to perform host-side AWS SSO login, then sync the result. +CACHE_DIR="$HOME/.aws/sso/cache" +HOST_CACHE="/tmp/.host-aws/sso/cache" +MARKER="/tmp/.sso-refresh-marker" + +touch "$MARKER" + +# Emit marker for Triple-C app to detect in terminal output +echo "###TRIPLE_C_SSO_REFRESH###" +echo "Waiting for SSO login to complete on host..." + +TIMEOUT=120 +ELAPSED=0 +while [ $ELAPSED -lt $TIMEOUT ]; do + if [ -d "$HOST_CACHE" ]; then + NEW=$(find "$HOST_CACHE" -name "*.json" -newer "$MARKER" 2>/dev/null | head -1) + if [ -n "$NEW" ]; then + mkdir -p "$CACHE_DIR" + cp -f "$HOST_CACHE"/*.json "$CACHE_DIR/" 2>/dev/null + chown -R "$(whoami)" "$CACHE_DIR" + echo "AWS SSO credentials refreshed successfully." + rm -f "$MARKER" + exit 0 + fi + fi + sleep 2 + ELAPSED=$((ELAPSED + 2)) +done + +echo "SSO refresh timed out (${TIMEOUT}s). Please try again." +rm -f "$MARKER" +exit 1