Skip to content

Commit 0e42df1

Browse files
committed
feat: add credential cache management with --clear-cache command
- Added --clear-cache flag to credential-process for clearing stored credentials - Implemented smart clearing that replaces credentials with expired dummies to avoid macOS keychain permission prompts - Enhanced OTEL helper to use credential-process, preventing direct keychain access - Fixed OTEL helper to fail cleanly instead of returning default user attributes - Added automatic credential clearing on authentication failures - Updated documentation with troubleshooting guidance This feature enables users to easily clear cached credentials when switching accounts or resolving authentication issues, while maintaining a smooth experience without repeated OS permission prompts.
1 parent ce9060a commit 0e42df1

File tree

6 files changed

+302
-112
lines changed

6 files changed

+302
-112
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,18 @@ The optional CloudWatch dashboard provides:
324324
- **Performance Metrics**: Response times and error rates
325325
- **User Activity**: Active users and authentication patterns
326326

327+
## Troubleshooting
328+
329+
### Clearing Cached Credentials
330+
331+
To force re-authentication:
332+
333+
```bash
334+
~/claude-code-with-bedrock/credential-process --clear-cache
335+
```
336+
337+
Note: This replaces credentials with expired dummies rather than deleting them, which prevents macOS from repeatedly asking for keychain permissions.
338+
327339
## CLI Commands
328340

329341
The guidance includes a comprehensive CLI tool (`ccwb`) for deployment and management:

assets/docs/LOCAL_TESTING.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ Understanding how authentication works helps you support users effectively. The
7878
To force a fresh authentication and observe the complete flow:
7979

8080
```bash
81-
# Clear any cached credentials
82-
rm -rf ~/claude-code-with-bedrock/cache/*
81+
# Clear any cached credentials (this replaces them with expired dummies to preserve keychain permissions)
82+
~/claude-code-with-bedrock/credential-process --clear-cache
8383

8484
# Trigger authentication
8585
aws sts get-caller-identity --profile ClaudeCode
@@ -167,3 +167,27 @@ claude
167167
```
168168

169169
Claude Code automatically uses the AWS profile for authentication. Behind the scenes, it calls the credential process whenever it needs to access Bedrock, with all authentication handled transparently.
170+
171+
### Important: AWS Credential Precedence
172+
173+
When testing, be aware that AWS CLI uses the following credential precedence order:
174+
175+
1. **Environment variables** (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) - highest priority
176+
2. Command line options
177+
3. Environment variable `AWS_PROFILE`
178+
4. Credential process from AWS config
179+
5. Config file credentials
180+
6. Instance metadata
181+
182+
If you have AWS credentials in environment variables (e.g., from other tools like Isengard), they will override the ClaudeCode profile. To ensure you're using the Claude Code authentication:
183+
184+
```bash
185+
# Clear any existing AWS credentials from environment
186+
unset AWS_ACCESS_KEY_ID
187+
unset AWS_SECRET_ACCESS_KEY
188+
unset AWS_SESSION_TOKEN
189+
190+
# Then use the ClaudeCode profile
191+
export AWS_PROFILE=ClaudeCode
192+
aws sts get-caller-identity
193+
```

source/claude_code_with_bedrock/cli/commands/cleanup.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import os
77
import shutil
8+
import subprocess
89
from pathlib import Path
910
from cleo.commands.command import Command
1011
from cleo.helpers import option
@@ -28,13 +29,26 @@ class CleanupCommand(Command):
2829
description="AWS profile name to remove (default: ClaudeCode)",
2930
flag=False,
3031
default="ClaudeCode"
32+
),
33+
option(
34+
"credentials-only",
35+
description="Only clear cached credentials without removing other components",
36+
flag=True
3137
)
3238
]
3339

3440
def handle(self) -> int:
3541
"""Execute the cleanup command."""
3642
console = Console()
3743

44+
profile_name = self.option("profile")
45+
force = self.option("force")
46+
credentials_only = self.option("credentials-only")
47+
48+
# Handle credentials-only mode
49+
if credentials_only:
50+
return self._clear_credentials_only(console, profile_name, force)
51+
3852
# Show what will be cleaned
3953
console.print(Panel.fit(
4054
"[bold yellow]Authentication Cleanup[/bold yellow]\n\n"
@@ -43,9 +57,6 @@ def handle(self) -> int:
4357
padding=(1, 2)
4458
))
4559

46-
profile_name = self.option("profile")
47-
force = self.option("force")
48-
4960
# List items to be removed
5061
items_to_remove = []
5162

@@ -152,4 +163,61 @@ def handle(self) -> int:
152163
console.print("• Run 'ccwb package' to create a new distribution")
153164
console.print("• Run 'ccwb test' to reinstall and test")
154165

166+
return 0
167+
168+
def _clear_credentials_only(self, console, profile_name, force):
169+
"""Clear only cached credentials without removing other components."""
170+
console.print(Panel.fit(
171+
"[bold cyan]Clear Cached Credentials[/bold cyan]\n\n"
172+
f"This will clear cached credentials for profile: {profile_name}",
173+
border_style="cyan",
174+
padding=(1, 2)
175+
))
176+
177+
# Check if credential-process exists
178+
credential_process = Path.home() / "claude-code-with-bedrock" / "credential-process"
179+
180+
if not credential_process.exists():
181+
console.print("[yellow]Credential process not found. Nothing to clear.[/yellow]")
182+
return 0
183+
184+
# Confirm clearing
185+
if not force:
186+
if not Confirm.ask("\n[bold yellow]Clear cached credentials?[/bold yellow]"):
187+
console.print("\n[yellow]Operation cancelled.[/yellow]")
188+
return 0
189+
190+
# Run the credential process with --clear-cache flag
191+
console.print("\n[bold]Clearing cached credentials...[/bold]")
192+
193+
try:
194+
result = subprocess.run(
195+
[str(credential_process), "--profile", profile_name, "--clear-cache"],
196+
capture_output=True,
197+
text=True,
198+
timeout=5
199+
)
200+
201+
if result.returncode == 0:
202+
if result.stderr:
203+
# Parse the output to show what was cleared
204+
for line in result.stderr.split('\n'):
205+
if line.strip():
206+
console.print(f" {line}")
207+
console.print("\n[green]✓ Cached credentials cleared successfully![/green]")
208+
else:
209+
console.print(f"[red]Failed to clear credentials: {result.stderr}[/red]")
210+
return 1
211+
212+
except subprocess.TimeoutExpired:
213+
console.print("[red]Operation timed out[/red]")
214+
return 1
215+
except Exception as e:
216+
console.print(f"[red]Error clearing credentials: {e}[/red]")
217+
return 1
218+
219+
console.print("\n[bold]Next steps:[/bold]")
220+
console.print("• The next AWS command will trigger re-authentication")
221+
console.print("• Use 'export AWS_PROFILE=ClaudeCode' to set the profile")
222+
155223
return 0

source/claude_code_with_bedrock/cli/commands/package.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,8 @@ def _create_installer(self, output_dir: Path, profile, built_executables, built_
727727
echo " export AWS_PROFILE=ClaudeCode"
728728
echo " aws sts get-caller-identity"
729729
echo
730+
echo "Note: Authentication will automatically open your browser when needed."
731+
echo
730732
'''
731733

732734
installer_path = output_dir / "install.sh"
@@ -776,6 +778,20 @@ def _create_documentation(self, output_dir: Path, profile, timestamp: str):
776778
- Check that port 8400 is available for the callback
777779
- Contact your IT administrator for help
778780
781+
### Authentication Behavior
782+
783+
The system handles authentication automatically:
784+
- Your browser will open when authentication is needed
785+
- Credentials are cached securely to avoid repeated logins
786+
- Bad credentials are automatically cleared and re-authenticated
787+
788+
To manually clear cached credentials (if needed):
789+
```bash
790+
~/claude-code-with-bedrock/credential-process --clear-cache
791+
```
792+
793+
This will force re-authentication on your next AWS command.
794+
779795
### Browser doesn't open
780796
Check that you're not in an SSH session. The browser needs to open on your local machine.
781797

source/cognito_auth/__main__.py

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,14 @@ def get_cached_credentials(self):
193193
return None
194194

195195
creds = json.loads(creds_json)
196+
197+
# Check for dummy/cleared credentials first
198+
# These are set when credentials are cleared to maintain keychain permissions
199+
if creds.get("AccessKeyId") == "EXPIRED":
200+
self._debug_print("Found cleared dummy credentials, need re-authentication")
201+
return None
196202

197-
# Validate expiration
203+
# Validate expiration for real credentials
198204
exp_str = creds.get("Expiration")
199205
if exp_str:
200206
exp_time = datetime.fromisoformat(exp_str.replace("Z", "+00:00"))
@@ -220,8 +226,13 @@ def get_cached_credentials(self):
220226
try:
221227
with open(session_file, "r") as f:
222228
creds = json.load(f)
229+
230+
# Check for dummy/cleared credentials first
231+
if creds.get("AccessKeyId") == "EXPIRED":
232+
self._debug_print("Found cleared dummy credentials in session file, need re-authentication")
233+
return None
223234

224-
# Validate expiration
235+
# Validate expiration for real credentials
225236
exp_str = creds.get("Expiration")
226237
if exp_str:
227238
exp_time = datetime.fromisoformat(exp_str.replace("Z", "+00:00"))
@@ -260,6 +271,66 @@ def save_credentials(self, credentials):
260271

261272
# Set restrictive permissions
262273
session_file.chmod(0o600)
274+
275+
def clear_cached_credentials(self):
276+
"""Clear all cached credentials for this profile"""
277+
cleared_items = []
278+
279+
# Clear from keyring by replacing with expired credentials
280+
# This maintains keychain access permissions on macOS
281+
try:
282+
if keyring.get_password("claude-code-with-bedrock", f"{self.profile}-credentials"):
283+
# Replace with expired dummy credential instead of deleting
284+
# This prevents macOS from asking for "Always Allow" again
285+
expired_credential = json.dumps({
286+
"Version": 1,
287+
"AccessKeyId": "EXPIRED",
288+
"SecretAccessKey": "EXPIRED",
289+
"SessionToken": "EXPIRED",
290+
"Expiration": "2000-01-01T00:00:00Z" # Far past date
291+
})
292+
keyring.set_password("claude-code-with-bedrock", f"{self.profile}-credentials", expired_credential)
293+
cleared_items.append("keyring credentials")
294+
except Exception as e:
295+
self._debug_print(f"Could not clear keyring credentials: {e}")
296+
297+
# Clear monitoring token from keyring
298+
try:
299+
if keyring.get_password("claude-code-with-bedrock", f"{self.profile}-monitoring"):
300+
# Replace with expired dummy token
301+
expired_token = json.dumps({
302+
"token": "EXPIRED",
303+
"expires": 0, # Expired timestamp
304+
"email": "",
305+
"profile": self.profile
306+
})
307+
keyring.set_password("claude-code-with-bedrock", f"{self.profile}-monitoring", expired_token)
308+
cleared_items.append("keyring monitoring token")
309+
except Exception as e:
310+
self._debug_print(f"Could not clear keyring monitoring token: {e}")
311+
312+
# Clear session files
313+
session_dir = Path.home() / ".claude-code-session"
314+
if session_dir.exists():
315+
session_file = session_dir / f"{self.profile}-session.json"
316+
monitoring_file = session_dir / f"{self.profile}-monitoring.json"
317+
318+
if session_file.exists():
319+
session_file.unlink()
320+
cleared_items.append("session file")
321+
322+
if monitoring_file.exists():
323+
monitoring_file.unlink()
324+
cleared_items.append("monitoring token file")
325+
326+
# Remove directory if empty
327+
try:
328+
if not any(session_dir.iterdir()):
329+
session_dir.rmdir()
330+
except Exception:
331+
pass
332+
333+
return cleared_items
263334

264335
def save_monitoring_token(self, id_token, token_claims):
265336
"""Save ID token for monitoring authentication"""
@@ -620,6 +691,23 @@ def get_aws_credentials(self, id_token, token_claims):
620691
return formatted_creds
621692

622693
except Exception as e:
694+
# Check if this is a credential error that suggests bad cached credentials
695+
error_str = str(e)
696+
if any(err in error_str for err in [
697+
"InvalidParameterException",
698+
"NotAuthorizedException",
699+
"ValidationError",
700+
"Invalid AccessKeyId",
701+
"Token is not from a supported provider"
702+
]):
703+
self._debug_print("Detected invalid credentials, clearing cache...")
704+
self.clear_cached_credentials()
705+
# Add helpful message for user
706+
raise Exception(
707+
f"Authentication failed - cached credentials were invalid and have been cleared.\n"
708+
f"Please try again to re-authenticate.\n"
709+
f"Original error: {error_str}"
710+
)
623711
raise Exception(f"Failed to get AWS credentials: {str(e)}")
624712

625713
def _wait_for_auth_completion(self, timeout=60):
@@ -752,19 +840,49 @@ def main():
752840
parser.add_argument(
753841
"--get-monitoring-token", action="store_true", help="Get cached monitoring token instead of AWS credentials"
754842
)
843+
parser.add_argument(
844+
"--clear-cache", action="store_true", help="Clear cached credentials and force re-authentication"
845+
)
755846

756847
args = parser.parse_args()
757848

758849
auth = MultiProviderAuth(profile=args.profile)
759850

851+
# Handle cache clearing request
852+
if args.clear_cache:
853+
cleared = auth.clear_cached_credentials()
854+
if cleared:
855+
print(f"Cleared cached credentials for profile '{args.profile}':", file=sys.stderr)
856+
for item in cleared:
857+
print(f" • {item}", file=sys.stderr)
858+
else:
859+
print(f"No cached credentials found for profile '{args.profile}'", file=sys.stderr)
860+
sys.exit(0)
861+
760862
# Handle monitoring token request
761863
if args.get_monitoring_token:
762864
token = auth.get_monitoring_token()
763865
if token:
764866
print(token)
765867
sys.exit(0)
766868
else:
767-
self._debug_print("No valid monitoring token found. Please authenticate first.")
869+
# No cached token, trigger authentication to get one
870+
auth._debug_print("No valid monitoring token found, triggering authentication...")
871+
try:
872+
# Run the normal authentication flow
873+
exit_code = auth.run()
874+
if exit_code == 0:
875+
# Authentication succeeded, try to get the token again
876+
token = auth.get_monitoring_token()
877+
if token:
878+
print(token)
879+
sys.exit(0)
880+
except Exception as e:
881+
auth._debug_print(f"Authentication failed: {e}")
882+
883+
# If we get here, couldn't get a token even after auth attempt
884+
# Return failure exit code so OTEL helper knows auth failed
885+
# This prevents OTEL helper from using default/unknown values
768886
sys.exit(1)
769887

770888
# Normal AWS credential flow

0 commit comments

Comments
 (0)