ssof 0.1.0__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.
@@ -0,0 +1,232 @@
1
+ Metadata-Version: 2.4
2
+ Name: ssof
3
+ Version: 0.1.0
4
+ Summary: A simple CLI tool to manage AWS SSO sessions
5
+ Author-email: Your Name <your.email@example.com>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: click>=8.3.0
10
+ Requires-Dist: boto3>=1.43.0
11
+ Requires-Dist: questionary>=2.1.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=9.0.0; extra == "dev"
14
+ Requires-Dist: black>=26.0.0; extra == "dev"
15
+
16
+ # Tori - AWS SSO Session Manager
17
+
18
+ Ultra-simple CLI tool to manage AWS SSO sessions across multiple organizations. Configure once per org, then just `tori assume <account>` and you're in!
19
+
20
+ ## Installation
21
+
22
+ Tori uses [uv](https://docs.astral.sh/uv/) for dependency management.
23
+
24
+ ```bash
25
+ # Install uv if you don't have it
26
+ brew install uv # or: curl -LsSf https://astral.sh/uv/install.sh | sh
27
+
28
+ # Sync dependencies and install tori in editable mode
29
+ uv sync
30
+
31
+ # Run tori via uv (no activation needed)
32
+ uv run tori --help
33
+
34
+ # Or activate the venv to use `tori` directly
35
+ source .venv/bin/activate
36
+ tori --help
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ 1. **Configure Tori with your SSO details**:
42
+ ```bash
43
+ tori configure my-org
44
+ ```
45
+ Enter your SSO start URL and region when prompted. Tori will authenticate and cache all available accounts.
46
+
47
+ 2. **List available accounts**:
48
+ ```bash
49
+ tori list
50
+ ```
51
+ This shows all AWS accounts you have access to via SSO across all configured orgs.
52
+
53
+ 3. **Assume a role**:
54
+ ```bash
55
+ tori assume my-account-name
56
+ ```
57
+ This will:
58
+ - Authenticate with AWS SSO (if needed)
59
+ - Get temporary credentials
60
+ - Back up your current default profile (if exists) to a named profile
61
+ - Configure your default AWS CLI profile automatically
62
+ - You're ready to use AWS CLI immediately!
63
+
64
+ ## Commands
65
+
66
+ ### `tori configure <org-name>`
67
+ Configure AWS SSO settings for an organization. You can configure multiple orgs.
68
+
69
+ **Example:**
70
+ ```bash
71
+ tori configure my-company
72
+ # Enter SSO start URL: https://my-company.awsapps.com/start
73
+ # Enter SSO region: us-east-1
74
+ ```
75
+
76
+ The first org you configure becomes the default. All accounts will be cached automatically.
77
+
78
+ ### `tori assume <account> [org-name]`
79
+ Assume an AWS SSO role and configure the default AWS profile with credentials.
80
+
81
+ **Examples:**
82
+ ```bash
83
+ # Assume by account name (uses default org)
84
+ tori assume production
85
+
86
+ # Assume by account ID
87
+ tori assume 123456789012
88
+
89
+ # Assume from specific org
90
+ tori assume production my-company
91
+
92
+ # Assume with specific role (skips interactive selection)
93
+ tori assume production my-company --role AdminRole
94
+ ```
95
+
96
+ **Profile Backup:** When you assume a new role, Tori automatically backs up your current default profile to `profile_<account_id>_<role_name>` so you can switch back later.
97
+
98
+ ### `tori refresh [org-name]`
99
+ Refresh cached accounts for an organization. Use this when new accounts or roles are added.
100
+
101
+ **Examples:**
102
+ ```bash
103
+ # Refresh default org
104
+ tori refresh
105
+
106
+ # Refresh specific org
107
+ tori refresh my-company
108
+ ```
109
+
110
+ ### `tori list [org-name]`
111
+ List all configured orgs and their AWS SSO accounts.
112
+
113
+ **Examples:**
114
+ ```bash
115
+ # List all orgs and accounts
116
+ tori list
117
+
118
+ # List accounts for specific org
119
+ tori list my-company
120
+ ```
121
+
122
+ ### `tori status`
123
+ Check your current AWS credentials status and see all backed up profiles.
124
+
125
+ ### `tori default <org-name>`
126
+ Set the default organization to use when org name is not specified.
127
+
128
+ **Example:**
129
+ ```bash
130
+ tori default my-company
131
+ ```
132
+
133
+ ## Configuration
134
+
135
+ Tori stores its configuration in `~/.tori/config.yaml`:
136
+
137
+ ```yaml
138
+ default_org: my-company
139
+ orgs:
140
+ my-company:
141
+ sso_start_url: https://my-company.awsapps.com/start
142
+ sso_region: us-east-1
143
+ cached_accounts:
144
+ production:
145
+ accountId: '123456789012'
146
+ accountName: production
147
+ email: aws-prod@company.com
148
+ roles:
149
+ - AdminRole
150
+ - ReadOnlyRole
151
+ another-org:
152
+ sso_start_url: https://another-org.awsapps.com/start
153
+ sso_region: us-west-2
154
+ cached_accounts: {}
155
+ active_profiles:
156
+ profile_123456789012_AdminRole:
157
+ account_id: '123456789012'
158
+ role_name: AdminRole
159
+ timestamp: '2025-11-21T10:30:00'
160
+ ```
161
+
162
+ Credentials are automatically written to `~/.aws/credentials` (default profile).
163
+
164
+ ## Multi-Org Workflow
165
+
166
+ Tori supports multiple SSO organizations:
167
+
168
+ 1. **Configure multiple orgs**:
169
+ ```bash
170
+ tori configure company-prod
171
+ tori configure company-dev
172
+ tori configure client-org
173
+ ```
174
+
175
+ 2. **Set a default org** (optional):
176
+ ```bash
177
+ tori default company-prod
178
+ ```
179
+
180
+ 3. **Assume roles**:
181
+ ```bash
182
+ # Uses default org
183
+ tori assume my-account
184
+
185
+ # Uses specific org
186
+ tori assume my-account company-dev
187
+ ```
188
+
189
+ 4. **List all orgs**:
190
+ ```bash
191
+ tori list
192
+ ```
193
+
194
+ ## Profile Management
195
+
196
+ When you assume a new role, Tori:
197
+ 1. Backs up your current default profile to a named profile
198
+ 2. Sets the new credentials as the default profile
199
+ 3. Tracks all backed up profiles in the config
200
+
201
+ **Backed up profile naming:** `profile_<account_id>_<role_name>`
202
+
203
+ **View backed up profiles:**
204
+ ```bash
205
+ tori status
206
+ ```
207
+
208
+ **Switch back to a previous profile:**
209
+ Simply use `tori assume` with the account and role you want to switch to.
210
+
211
+ ## How it Works
212
+
213
+ 1. **One-time setup per org**: Store your SSO start URL and region
214
+ 2. **Automatic caching**: Accounts and roles are cached during configuration
215
+ 3. **Explicit refresh**: Only re-fetch accounts when you run `tori refresh`
216
+ 4. **Assume roles**:
217
+ - Authenticate via AWS SSO (browser-based, only when needed)
218
+ - Get temporary credentials for the selected account and role
219
+ - Backup current default profile
220
+ - Write new credentials to default AWS profile
221
+ - Use AWS CLI normally!
222
+
223
+ No need to manage multiple profiles manually or remember account details - just use the account name!
224
+
225
+ ## Requirements
226
+
227
+ - Python 3.8+
228
+ - boto3 (AWS SDK)
229
+ - click (CLI framework)
230
+ - questionary (interactive prompts)
231
+ - pyyaml (config management)
232
+ - Internet connection for SSO authentication
@@ -0,0 +1,7 @@
1
+ tori/cli.py,sha256=5ftBo0YtFlomQbqgePBH6s5e-W9zm1MCyBKSnbZ1bYs,7269
2
+ tori/sso_manager.py,sha256=2lvktGkikY4C7r5_STjR1mmNXjNexQnoBFzy4XUBjUc,35212
3
+ ssof-0.1.0.dist-info/METADATA,sha256=RC3PvQmP9PNf9_7GA-pWAGkszGpNCrI6wog31VP8vSE,5780
4
+ ssof-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ ssof-0.1.0.dist-info/entry_points.txt,sha256=QUl1uF_fp2nQLJIWwG2qqHX5sOtoZY7ABkI2OlrcAa0,38
6
+ ssof-0.1.0.dist-info/top_level.txt,sha256=6r-1m2MoXcq1D2SH_y3WuK_bsBZKAs1lceoQH5kve14,5
7
+ ssof-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ssof = tori.cli:cli
@@ -0,0 +1 @@
1
+ tori
tori/cli.py ADDED
@@ -0,0 +1,215 @@
1
+ """CLI interface for Tori"""
2
+
3
+ import click
4
+ from tori.sso_manager import SSOManager
5
+
6
+
7
+ # Lazy: questionary pulls in prompt_toolkit (~100ms). Only construct when needed.
8
+ _STYLE_SPEC = [
9
+ ('qmark', 'fg:#5fafff bold'),
10
+ ('question', 'fg:#ffffff bold'),
11
+ ('answer', 'fg:#5fafff bold'),
12
+ ('pointer', 'fg:#5fafff bold'),
13
+ ('highlighted', 'fg:#000000 bg:#5fafff bold'),
14
+ ('selected', 'fg:#000000 bg:#5fafff'),
15
+ ('completion-menu', 'bg:#3a3a3a fg:#ffffff'),
16
+ ('completion-menu.completion', 'bg:#3a3a3a fg:#ffffff'),
17
+ ('completion-menu.completion.current', 'bg:#5fafff fg:#000000 bold'),
18
+ ('completion-menu.meta.completion', 'bg:#3a3a3a fg:#aaaaaa'),
19
+ ('completion-menu.meta.completion.current', 'bg:#5fafff fg:#000000'),
20
+ ('scrollbar.background', 'bg:#3a3a3a'),
21
+ ('scrollbar.button', 'bg:#5fafff'),
22
+ ]
23
+
24
+
25
+ def tori_style():
26
+ """Construct the questionary Style on first use (defers prompt_toolkit import)."""
27
+ from questionary import Style
28
+ return Style(_STYLE_SPEC)
29
+
30
+
31
+ @click.group()
32
+ @click.version_option()
33
+ @click.option('-v', '--verbose', is_flag=True, help='Print full tracebacks on errors')
34
+ @click.pass_context
35
+ def cli(ctx, verbose):
36
+ """Tori - Simple AWS SSO session management"""
37
+ ctx.ensure_object(dict)
38
+ ctx.obj['verbose'] = verbose
39
+
40
+
41
+ def _manager(ctx) -> SSOManager:
42
+ return SSOManager(verbose=ctx.obj.get('verbose', False) if ctx.obj else False)
43
+
44
+
45
+ @cli.group()
46
+ def config():
47
+ """Manage Tori SSO configuration (orgs)"""
48
+ pass
49
+
50
+
51
+ @config.command('add')
52
+ @click.argument('org_name')
53
+ @click.pass_context
54
+ def config_add(ctx, org_name):
55
+ """Add an SSO org configuration"""
56
+ _manager(ctx).add_org(org_name)
57
+
58
+
59
+ @config.command('remove')
60
+ @click.argument('org_name')
61
+ @click.option('--yes', '-y', is_flag=True, help='Skip confirmation prompt')
62
+ @click.pass_context
63
+ def config_remove(ctx, org_name, yes):
64
+ """Remove an SSO org configuration"""
65
+ _manager(ctx).remove_org(org_name, confirm=not yes)
66
+
67
+
68
+ @cli.command()
69
+ @click.argument('account', required=False)
70
+ @click.option('--role', default=None, help='Role name to assume (skips interactive selection)')
71
+ @click.option('--profile', default='default', show_default=True,
72
+ help='AWS profile name to write credentials into (~/.aws/credentials)')
73
+ @click.pass_context
74
+ def assume(ctx, account, role, profile):
75
+ """Assume an AWS SSO role and write credentials to an AWS profile
76
+
77
+ By default writes to the [default] profile. Use --profile to write to a
78
+ named profile (e.g., --profile dev keeps your default untouched).
79
+
80
+ ACCOUNT is an account ID or name. AWS account IDs are globally unique,
81
+ so no org needs to be specified. If omitted, an interactive picker
82
+ shows accounts across all configured orgs.
83
+
84
+ Examples:
85
+ tori assume # picker, writes [default]
86
+ tori assume 123456789012
87
+ tori assume my-account --role AdminRole
88
+ tori assume my-account --profile dev # writes [dev]
89
+ """
90
+ manager = _manager(ctx)
91
+
92
+ if not account:
93
+ scope = manager.config.get('orgs', {})
94
+ if not scope:
95
+ click.echo("No orgs configured. Run 'tori config add <org-name>' first.")
96
+ return
97
+
98
+ choices = []
99
+ choice_map = {} # label -> account_id
100
+ for o_name, o_config in scope.items():
101
+ cached = o_config.get('cached_accounts', {})
102
+ for acc_id, a_data in sorted(cached.items(), key=lambda kv: kv[1].get('accountName', '')):
103
+ label = f"{a_data.get('accountName', '')} [{acc_id}] ({o_name})"
104
+ choices.append(label)
105
+ choice_map[label] = acc_id
106
+
107
+ if not choices:
108
+ click.echo("No cached accounts. Run 'tori refresh' first.")
109
+ return
110
+
111
+ try:
112
+ import questionary
113
+ selection = questionary.autocomplete(
114
+ 'Account (name, ID, or org):',
115
+ choices=choices,
116
+ match_middle=True,
117
+ ignore_case=True,
118
+ style=tori_style()
119
+ ).ask()
120
+
121
+ if not selection:
122
+ return
123
+ account = choice_map.get(selection, selection)
124
+ except (KeyboardInterrupt, EOFError):
125
+ click.echo("\nCancelled")
126
+ return
127
+
128
+ manager.assume_role(account, role, profile)
129
+
130
+
131
+ @cli.command()
132
+ @click.argument('org_name', required=False)
133
+ @click.pass_context
134
+ def refresh(ctx, org_name):
135
+ """Refresh cached accounts for an org (or all orgs if omitted)"""
136
+ manager = _manager(ctx)
137
+ if org_name:
138
+ manager.refresh_accounts(org_name)
139
+ else:
140
+ for name in manager.config.get('orgs', {}).keys():
141
+ print(f"\n=== Refreshing '{name}' ===")
142
+ manager.refresh_accounts(name)
143
+
144
+
145
+ @cli.command()
146
+ @click.argument('org_name', required=False)
147
+ @click.pass_context
148
+ def list(ctx, org_name):
149
+ """List all configured orgs"""
150
+ _manager(ctx).list_accounts(org_name)
151
+
152
+
153
+ @cli.command()
154
+ @click.option('--profile', default='default', show_default=True,
155
+ help='AWS profile whose credentials to sign in with')
156
+ @click.option('--destination', default=None,
157
+ help='Console URL to land on (defaults to region console homepage)')
158
+ @click.pass_context
159
+ def console(ctx, profile, destination):
160
+ """Open the AWS web console signed in with the current session"""
161
+ _manager(ctx).open_console(profile=profile, destination=destination)
162
+
163
+
164
+ @cli.command()
165
+ @click.option('--all', 'clear_sso', is_flag=True, help='Also clear cached SSO tokens (forces re-auth next time)')
166
+ @click.pass_context
167
+ def logout(ctx, clear_sso):
168
+ """Clear the default AWS profile (and optionally SSO tokens)"""
169
+ _manager(ctx).logout(clear_sso=clear_sso)
170
+
171
+
172
+ @cli.command()
173
+ @click.argument('profile_name', required=False)
174
+ @click.option('--profile', 'target', default='default', show_default=True,
175
+ help='AWS profile to write the restored credentials into')
176
+ @click.pass_context
177
+ def switch(ctx, profile_name, target):
178
+ """Switch a backed-up tori session into an AWS profile (default: [default])"""
179
+ _manager(ctx).switch_profile(profile_name, target=target)
180
+
181
+
182
+ @cli.command()
183
+ @click.option('--verify', is_flag=True, help='Verify credentials with AWS STS (slower)')
184
+ @click.pass_context
185
+ def status(ctx, verify):
186
+ """Check AWS credentials status and backed up profiles"""
187
+ _manager(ctx).show_status(verify=verify)
188
+
189
+
190
+ @cli.command('_complete_accounts', hidden=True)
191
+ def _complete_accounts():
192
+ """Print cached account names and IDs (for shell tab-completion)."""
193
+ import json as _json
194
+ from pathlib import Path as _Path
195
+ config_file = _Path.home() / '.tori' / 'config.json'
196
+ if not config_file.exists():
197
+ return
198
+ try:
199
+ with open(config_file, 'r') as f:
200
+ config = _json.load(f)
201
+ except (ValueError, OSError):
202
+ return
203
+ seen = set()
204
+ for org_config in config.get('orgs', {}).values():
205
+ for acc_id, acc_data in org_config.get('cached_accounts', {}).items():
206
+ name = acc_data.get('accountName', '')
207
+ for candidate in (name, acc_id):
208
+ if candidate and candidate not in seen:
209
+ seen.add(candidate)
210
+ click.echo(candidate)
211
+
212
+
213
+ if __name__ == '__main__':
214
+ cli()
215
+
tori/sso_manager.py ADDED
@@ -0,0 +1,869 @@
1
+ """AWS SSO session management logic"""
2
+
3
+ import os
4
+ import json
5
+ import configparser
6
+ import hashlib
7
+ import time
8
+ import webbrowser
9
+ from pathlib import Path
10
+ from typing import Optional, List
11
+ from datetime import datetime, timezone
12
+
13
+ # boto3 and questionary are imported lazily inside methods that need them
14
+ # to keep cold-start fast for commands like list/status/whoami/logout/switch.
15
+
16
+
17
+ class SSOManager:
18
+ def __init__(self, verbose: bool = False):
19
+ self.verbose = verbose
20
+ self.config_dir = Path.home() / '.tori'
21
+ self.config_file = self.config_dir / 'config.json'
22
+ self.config_dir.mkdir(exist_ok=True)
23
+ self.config = self._load_config()
24
+ self.aws_config_path = Path.home() / '.aws' / 'config'
25
+ self.aws_credentials_path = Path.home() / '.aws' / 'credentials'
26
+ self.tori_credentials_path = self.config_dir / 'credentials'
27
+ self._prune_expired_profiles()
28
+
29
+ def _prune_expired_profiles(self):
30
+ """Remove expired backed-up sessions from config and ~/.aws/credentials."""
31
+ active = self.config.get('active_profiles', {})
32
+ if not active:
33
+ return
34
+
35
+ now = time.time()
36
+ expired = [
37
+ name for name, info in active.items()
38
+ if info.get('expires_at') and info['expires_at'] < now
39
+ ]
40
+ if not expired:
41
+ return
42
+
43
+ for name in expired:
44
+ del active[name]
45
+
46
+ # Strip the matching sections from ~/.tori/credentials
47
+ if self.tori_credentials_path.exists():
48
+ cp = configparser.ConfigParser()
49
+ cp.read(self.tori_credentials_path)
50
+ removed = False
51
+ for name in expired:
52
+ if name in cp:
53
+ cp.remove_section(name)
54
+ removed = True
55
+ if removed:
56
+ with open(self.tori_credentials_path, 'w') as f:
57
+ cp.write(f)
58
+
59
+ self._save_config()
60
+
61
+ def _load_config(self) -> dict:
62
+ """Load tori configuration"""
63
+ if self.config_file.exists():
64
+ with open(self.config_file, 'r') as f:
65
+ return json.load(f) or self._empty_config()
66
+ return self._empty_config()
67
+
68
+ @staticmethod
69
+ def _empty_config() -> dict:
70
+ return {'orgs': {}, 'active_profiles': {}, 'last_assumed_role': None}
71
+
72
+ def _save_config(self):
73
+ with open(self.config_file, 'w') as f:
74
+ json.dump(self.config, f, indent=2)
75
+
76
+ def _get_org(self, org_name: Optional[str] = None) -> tuple:
77
+ """Get org config by name. If omitted and only one org exists, use it."""
78
+ if not org_name:
79
+ orgs = self.config.get('orgs', {})
80
+ if len(orgs) == 1:
81
+ org_name = next(iter(orgs))
82
+ else:
83
+ raise Exception("Multiple orgs configured. Specify which org to use.")
84
+
85
+ if org_name not in self.config['orgs']:
86
+ raise Exception(f"Org '{org_name}' not found. Run 'tori config add {org_name}' first.")
87
+
88
+ return org_name, self.config['orgs'][org_name]
89
+
90
+ def _find_account(self, account: str):
91
+ """Find account across all orgs by ID (exact) or name (substring).
92
+ Returns (org_name, account_data) or (None, None)."""
93
+ scope = self.config.get('orgs', {})
94
+ account_lower = account.lower()
95
+ exact_id = []
96
+ name_partial = []
97
+
98
+ for o_name, o_config in scope.items():
99
+ cached = o_config.get('cached_accounts', {})
100
+ for acc_id, a_data in cached.items():
101
+ if acc_id == account:
102
+ exact_id.append((o_name, a_data))
103
+ else:
104
+ a_name = a_data.get('accountName', '')
105
+ if a_name == account:
106
+ exact_id.append((o_name, a_data))
107
+ elif account_lower in a_name.lower():
108
+ name_partial.append((o_name, a_data))
109
+
110
+ if len(exact_id) == 1:
111
+ return exact_id[0]
112
+ if len(exact_id) > 1:
113
+ # Should be impossible for IDs since they're unique; can happen for duplicate names
114
+ labels = [f"{a['accountName']} [{a['accountId']}] ({o})" for o, a in exact_id]
115
+ raise Exception(f"Multiple accounts named '{account}': {', '.join(labels)}. Use the account ID.")
116
+ if len(name_partial) == 1:
117
+ return name_partial[0]
118
+ if len(name_partial) > 1:
119
+ labels = [f"{a['accountName']} [{a['accountId']}]" for _, a in name_partial]
120
+ raise Exception(f"Multiple matches: {', '.join(labels)}. Be more specific or use the account ID.")
121
+ return None, None
122
+
123
+ def _get_sso_token(self, org_name: str, org_config: dict) -> str:
124
+ """Get SSO access token"""
125
+ sso_start_url = org_config.get('sso_start_url')
126
+ sso_region = org_config.get('sso_region')
127
+
128
+ if not sso_start_url or not sso_region:
129
+ raise Exception(f"SSO not configured for org '{org_name}'. Run 'tori config add {org_name}' first.")
130
+
131
+ # Construct the cache file path using the same logic as _sso_login
132
+ sso_cache_dir = Path.home() / '.aws' / 'sso' / 'cache'
133
+ cache_key = hashlib.sha1(sso_start_url.encode()).hexdigest()
134
+ token_file = sso_cache_dir / f"{cache_key}.json"
135
+
136
+ if not token_file.exists():
137
+ raise Exception("No SSO token found for this org. Please authenticate.")
138
+
139
+ with open(token_file, 'r') as f:
140
+ token_data = json.load(f)
141
+
142
+ # Check if token is expired
143
+ expires_at = token_data.get('expiresAt')
144
+ if expires_at:
145
+ # Handle both timestamp and ISO format
146
+ try:
147
+ if isinstance(expires_at, (int, float)):
148
+ expires_dt = datetime.fromtimestamp(expires_at, tz=timezone.utc)
149
+ else:
150
+ expires_dt = datetime.fromisoformat(expires_at.replace('Z', '+00:00'))
151
+
152
+ if expires_dt < datetime.now(timezone.utc):
153
+ raise Exception("SSO token expired. Please authenticate again.")
154
+ except ValueError as e:
155
+ print(f"Warning: Could not parse token expiration: {e}")
156
+
157
+ access_token = token_data.get('accessToken')
158
+ if not access_token:
159
+ raise Exception("Invalid token cache - no accessToken found")
160
+
161
+ return access_token
162
+
163
+ def _get_or_refresh_sso_token(self, org_name: str, org_config: dict) -> Optional[str]:
164
+ """Return a valid SSO access token, re-authenticating only if missing or expired."""
165
+ try:
166
+ return self._get_sso_token(org_name, org_config)
167
+ except FileNotFoundError:
168
+ pass # token cache missing
169
+ except Exception as e:
170
+ msg = str(e).lower()
171
+ if 'expired' in msg or 'no sso token' in msg or 'no accesstoken' in msg:
172
+ pass # expected — fall through to re-auth
173
+ else:
174
+ if self.verbose:
175
+ print(f"Token check failed: {e}")
176
+ else:
177
+ print(f"Token check failed: {e}")
178
+ return None
179
+ print("Re-authenticating...")
180
+ return self._sso_login(org_name, org_config)
181
+
182
+ def _sso_login(self, org_name: str, org_config: dict):
183
+ """Perform SSO login"""
184
+ sso_start_url = org_config.get('sso_start_url')
185
+ sso_region = org_config.get('sso_region')
186
+
187
+ print(f"Authenticating with AWS SSO (org: {org_name})...")
188
+ print(f" Start URL: {sso_start_url}")
189
+ print(f" Region: {sso_region}")
190
+
191
+ try:
192
+ import boto3
193
+ sso_oidc = boto3.client('sso-oidc', region_name=sso_region)
194
+
195
+ # Register client
196
+ client_response = sso_oidc.register_client(
197
+ clientName='tori-cli',
198
+ clientType='public'
199
+ )
200
+
201
+ client_id = client_response['clientId']
202
+ client_secret = client_response['clientSecret']
203
+
204
+ # Start device authorization
205
+ auth_response = sso_oidc.start_device_authorization(
206
+ clientId=client_id,
207
+ clientSecret=client_secret,
208
+ startUrl=sso_start_url
209
+ )
210
+
211
+ device_code = auth_response['deviceCode']
212
+ user_code = auth_response['userCode']
213
+ verification_uri = auth_response['verificationUriComplete']
214
+
215
+ print(f"Opening browser for authentication...")
216
+ print(f" Verification URL: {verification_uri}")
217
+ print(f" User code: {user_code}")
218
+
219
+ # Open browser
220
+ webbrowser.open(verification_uri)
221
+
222
+ print(f"Waiting for authentication...")
223
+
224
+ # Poll for token
225
+ expires_at = time.time() + auth_response['expiresIn']
226
+ interval = auth_response['interval']
227
+
228
+ while time.time() < expires_at:
229
+ try:
230
+ token_response = sso_oidc.create_token(
231
+ clientId=client_id,
232
+ clientSecret=client_secret,
233
+ grantType='urn:ietf:params:oauth:grant-type:device_code',
234
+ deviceCode=device_code
235
+ )
236
+
237
+ access_token = token_response['accessToken']
238
+
239
+ # Save token to cache
240
+ sso_cache_dir = Path.home() / '.aws' / 'sso' / 'cache'
241
+ sso_cache_dir.mkdir(parents=True, exist_ok=True)
242
+
243
+ # Use a stable filename based on start URL
244
+ cache_key = hashlib.sha1(sso_start_url.encode()).hexdigest()
245
+ cache_file = sso_cache_dir / f"{cache_key}.json"
246
+
247
+ expires_timestamp = datetime.now(timezone.utc).timestamp() + token_response['expiresIn']
248
+ expires_iso = datetime.fromtimestamp(expires_timestamp, tz=timezone.utc).isoformat()
249
+
250
+ with open(cache_file, 'w') as f:
251
+ json.dump({
252
+ 'accessToken': access_token,
253
+ 'expiresAt': expires_iso,
254
+ 'region': sso_region,
255
+ 'startUrl': sso_start_url
256
+ }, f)
257
+
258
+ print(f"Authentication successful!")
259
+ return access_token
260
+
261
+ except sso_oidc.exceptions.AuthorizationPendingException:
262
+ time.sleep(interval)
263
+ except Exception as e:
264
+ print(f"Authentication failed: {e}")
265
+ raise
266
+
267
+ raise Exception("Authentication timed out")
268
+
269
+ except Exception as e:
270
+ print(f"SSO login failed: {e}")
271
+ if self.verbose:
272
+ import traceback as _tb; _tb.print_exc()
273
+ return None
274
+
275
+ def _cache_accounts(self, org_name: str, org_config: dict, access_token: str):
276
+ """Cache all AWS SSO accounts for an org"""
277
+ import boto3
278
+ sso_region = org_config.get('sso_region')
279
+ sso = boto3.client('sso', region_name=sso_region)
280
+
281
+ print("Fetching all accounts (this may take a while for large organizations)...")
282
+ all_accounts = []
283
+ next_token = None
284
+ page_num = 1
285
+
286
+ while True:
287
+ try:
288
+ print(f" - Fetching account page {page_num}...")
289
+ if next_token:
290
+ accounts_response = sso.list_accounts(accessToken=access_token, nextToken=next_token)
291
+ else:
292
+ accounts_response = sso.list_accounts(accessToken=access_token)
293
+
294
+ all_accounts.extend(accounts_response.get('accountList', []))
295
+
296
+ next_token = accounts_response.get('nextToken')
297
+ if not next_token:
298
+ break
299
+ page_num += 1
300
+ except Exception as e:
301
+ print(f"Error fetching accounts: {e}")
302
+ return
303
+
304
+ # Cache keyed by account ID (globally unique). Roles fetched on demand.
305
+ account_cache = {}
306
+ for account in all_accounts:
307
+ acc_id = account.get('accountId')
308
+ account_cache[acc_id] = {
309
+ 'accountId': acc_id,
310
+ 'accountName': account.get('accountName', ''),
311
+ }
312
+
313
+ org_config['cached_accounts'] = account_cache
314
+ self._save_config()
315
+ print(f"Cached {len(account_cache)} accounts for org '{org_name}'")
316
+
317
+ def _fetch_roles(self, sso_client, access_token: str, account_id: str) -> List[str]:
318
+ """Fetch roles for a single account on demand (paginated)."""
319
+ all_roles = []
320
+ next_token = None
321
+ while True:
322
+ kwargs = {'accessToken': access_token, 'accountId': account_id}
323
+ if next_token:
324
+ kwargs['nextToken'] = next_token
325
+ response = sso_client.list_account_roles(**kwargs)
326
+ all_roles.extend(response.get('roleList', []))
327
+ next_token = response.get('nextToken')
328
+ if not next_token:
329
+ break
330
+ return [role['roleName'] for role in all_roles]
331
+
332
+ def _select_role_interactive(self, roles: List[str], account_name: str) -> Optional[str]:
333
+ """Interactive role selection with arrow keys"""
334
+ if not roles:
335
+ return None
336
+
337
+ if len(roles) == 1:
338
+ return roles[0]
339
+
340
+ try:
341
+ import questionary
342
+ from tori.cli import tori_style
343
+ role = questionary.select(
344
+ f"Select role for {account_name}:",
345
+ choices=roles,
346
+ use_arrow_keys=True,
347
+ style=tori_style()
348
+ ).ask()
349
+ return role
350
+ except (KeyboardInterrupt, EOFError):
351
+ print("\nCancelled")
352
+ return None
353
+
354
+ def _backup_current_profile(self, last_role: dict, source_profile: str = 'default'):
355
+ """Snapshot the current AWS profile creds into ~/.tori/credentials with metadata."""
356
+ account_id = last_role.get('account_id')
357
+ role_name = last_role.get('role_name')
358
+ if not account_id or not role_name:
359
+ return None
360
+
361
+ # Read current creds from ~/.aws/credentials
362
+ aws_cp = configparser.ConfigParser()
363
+ if self.aws_credentials_path.exists():
364
+ aws_cp.read(self.aws_credentials_path)
365
+ else:
366
+ return None
367
+
368
+ if source_profile not in aws_cp:
369
+ return None
370
+
371
+ backup_profile_name = f"profile_{account_id}_{role_name}"
372
+
373
+ # Write into ~/.tori/credentials
374
+ tori_cp = configparser.ConfigParser()
375
+ if self.tori_credentials_path.exists():
376
+ tori_cp.read(self.tori_credentials_path)
377
+ if backup_profile_name not in tori_cp:
378
+ tori_cp[backup_profile_name] = {}
379
+ for key in ['aws_access_key_id', 'aws_secret_access_key', 'aws_session_token']:
380
+ if key in aws_cp[source_profile]:
381
+ tori_cp[backup_profile_name][key] = aws_cp[source_profile][key]
382
+
383
+ with open(self.tori_credentials_path, 'w') as f:
384
+ tori_cp.write(f)
385
+ os.chmod(self.tori_credentials_path, 0o600)
386
+
387
+ self.config['active_profiles'][backup_profile_name] = {
388
+ 'account_id': account_id,
389
+ 'account_name': last_role.get('account_name'),
390
+ 'role_name': role_name,
391
+ 'org_name': last_role.get('org_name'),
392
+ 'region': last_role.get('region'),
393
+ 'expires_at': last_role.get('expires_at'),
394
+ 'timestamp': datetime.now().isoformat(),
395
+ }
396
+ self._save_config()
397
+
398
+ return backup_profile_name
399
+
400
+ def assume_role(self, account: str, role: Optional[str] = None, profile: str = 'default'):
401
+ """Assume an AWS SSO role and write credentials to the named AWS profile (default: [default])."""
402
+
403
+ try:
404
+ org_name, target_account = self._find_account(account)
405
+ except Exception as e:
406
+ print(f"Error: {e}")
407
+ return
408
+
409
+ if not target_account:
410
+ print(f"Account '{account}' not found. Run 'tori list' to see configured orgs.")
411
+ return
412
+
413
+ org_config = self.config['orgs'][org_name]
414
+ print(f"Assuming AWS SSO session for: {target_account['accountName']} (org: {org_name})")
415
+
416
+ account_id = target_account['accountId']
417
+ account_name = target_account['accountName']
418
+
419
+ try:
420
+ access_token = self._get_or_refresh_sso_token(org_name, org_config)
421
+
422
+ if not access_token:
423
+ print("Failed to get access token")
424
+ return
425
+
426
+ import boto3
427
+ sso_region = org_config.get('sso_region')
428
+ sso = boto3.client('sso', region_name=sso_region)
429
+
430
+ # Fetch roles for this account on demand
431
+ print(f"Fetching available roles for {account_name}...")
432
+ available_roles = self._fetch_roles(sso, access_token, account_id)
433
+
434
+ if not available_roles:
435
+ print(f"No roles available for account {account_name}")
436
+ return
437
+
438
+ # Determine role
439
+ if role:
440
+ if role not in available_roles:
441
+ print(f"Role '{role}' not found. Available roles: {', '.join(available_roles)}")
442
+ return
443
+ role_name = role
444
+ elif len(available_roles) > 1:
445
+ role_name = self._select_role_interactive(available_roles, account_name)
446
+ if not role_name:
447
+ return
448
+ else:
449
+ role_name = available_roles[0]
450
+
451
+ print(f"Using role: {role_name}")
452
+
453
+ # Get role credentials
454
+ creds_response = sso.get_role_credentials(
455
+ accessToken=access_token,
456
+ accountId=account_id,
457
+ roleName=role_name
458
+ )
459
+
460
+ credentials = creds_response['roleCredentials']
461
+
462
+ # Back up the existing creds in the target profile (if tori-managed)
463
+ aws_cp = configparser.ConfigParser()
464
+ if self.aws_credentials_path.exists():
465
+ aws_cp.read(self.aws_credentials_path)
466
+ last_role = self.config.get('last_assumed_role')
467
+ if (profile in aws_cp
468
+ and 'aws_session_token' in aws_cp[profile]
469
+ and last_role):
470
+ backup_name = self._backup_current_profile(last_role, source_profile=profile)
471
+ if backup_name:
472
+ print(f"Backed up previous session to: {backup_name}")
473
+
474
+ # Write credentials to ~/.aws/credentials under [<profile>]
475
+ print(f"Writing credentials to AWS profile: {profile}")
476
+ aws_dir = Path.home() / '.aws'
477
+ aws_dir.mkdir(exist_ok=True)
478
+
479
+ cred_cp = configparser.ConfigParser()
480
+ if self.aws_credentials_path.exists():
481
+ cred_cp.read(self.aws_credentials_path)
482
+ if profile not in cred_cp:
483
+ cred_cp[profile] = {}
484
+ cred_cp[profile]['aws_access_key_id'] = credentials['accessKeyId']
485
+ cred_cp[profile]['aws_secret_access_key'] = credentials['secretAccessKey']
486
+ cred_cp[profile]['aws_session_token'] = credentials['sessionToken']
487
+
488
+ with open(self.aws_credentials_path, 'w') as f:
489
+ cred_cp.write(f)
490
+
491
+ # Update region in ~/.aws/config — note: section is [default] for default,
492
+ # and [profile <name>] for named profiles
493
+ cfg_cp = configparser.ConfigParser()
494
+ if self.aws_config_path.exists():
495
+ cfg_cp.read(self.aws_config_path)
496
+ cfg_section = 'default' if profile == 'default' else f'profile {profile}'
497
+ if cfg_section not in cfg_cp:
498
+ cfg_cp[cfg_section] = {}
499
+ cfg_cp[cfg_section]['region'] = sso_region
500
+
501
+ with open(self.aws_config_path, 'w') as f:
502
+ cfg_cp.write(f)
503
+
504
+ expires_timestamp = credentials['expiration'] / 1000
505
+ expires_dt = datetime.fromtimestamp(expires_timestamp)
506
+
507
+ # Update last assumed role in config
508
+ self.config['last_assumed_role'] = {
509
+ 'account_id': account_id,
510
+ 'account_name': account_name,
511
+ 'role_name': role_name,
512
+ 'org_name': org_name,
513
+ 'region': sso_region,
514
+ 'expires_at': expires_timestamp,
515
+ }
516
+ self._save_config()
517
+
518
+ print(f"\nSuccess! AWS profile '{profile}' configured")
519
+ print(f" Org: {org_name}")
520
+ print(f" Account: {account_name} ({account_id})")
521
+ print(f" Role: {role_name}")
522
+ print(f" Region: {sso_region}")
523
+ print(f" Expires: {expires_dt.strftime('%Y-%m-%d %H:%M:%S')}")
524
+
525
+ except Exception as e:
526
+ print(f"Failed to assume role: {e}")
527
+ if self.verbose:
528
+ import traceback as _tb; _tb.print_exc()
529
+
530
+ def add_org(self, org_name: str):
531
+ """Interactively add an SSO org to the tori config and cache its accounts."""
532
+ print(f"Tori AWS SSO Configuration (org: {org_name})")
533
+ print("=" * 50)
534
+ print()
535
+
536
+ start_url = input("Enter SSO start URL: ").strip()
537
+ region = input("Enter SSO region (e.g., us-east-1): ").strip()
538
+
539
+ if start_url and region:
540
+ if 'orgs' not in self.config:
541
+ self.config['orgs'] = {}
542
+
543
+ self.config['orgs'][org_name] = {
544
+ 'sso_start_url': start_url,
545
+ 'sso_region': region,
546
+ 'cached_accounts': {}
547
+ }
548
+
549
+ self._save_config()
550
+ print()
551
+ print("Configuration saved")
552
+ print(f" Org: {org_name}")
553
+ print(f" Start URL: {start_url}")
554
+ print(f" Region: {region}")
555
+ print()
556
+
557
+ # Authenticate and cache accounts immediately
558
+ print("Authenticating and caching accounts...")
559
+ try:
560
+ access_token = self._sso_login(org_name, self.config['orgs'][org_name])
561
+ if access_token:
562
+ self._cache_accounts(org_name, self.config['orgs'][org_name], access_token)
563
+ print(f"\nDone! You can now use 'tori assume <account-name-or-id>'")
564
+ else:
565
+ print("Authentication failed")
566
+ except Exception as e:
567
+ print(f"Error during authentication: {e}")
568
+ else:
569
+ print("Configuration cancelled")
570
+
571
+ def refresh_accounts(self, org_name: Optional[str] = None):
572
+ """Refresh cached accounts for an org"""
573
+ try:
574
+ org_name, org_config = self._get_org(org_name)
575
+ except Exception as e:
576
+ print(f"Error: {e}")
577
+ return
578
+
579
+ print(f"Refreshing accounts for org: {org_name}")
580
+
581
+ access_token = self._get_or_refresh_sso_token(org_name, org_config)
582
+ if not access_token:
583
+ print("Failed to get access token")
584
+ return
585
+
586
+ self._cache_accounts(org_name, org_config, access_token)
587
+ print("Accounts refreshed successfully")
588
+
589
+ def list_accounts(self, org_name: Optional[str] = None):
590
+ """List configured orgs with account counts"""
591
+ if org_name:
592
+ if org_name not in self.config['orgs']:
593
+ print(f"Org '{org_name}' not found.")
594
+ return
595
+ orgs_to_list = {org_name: self.config['orgs'][org_name]}
596
+ else:
597
+ orgs_to_list = self.config['orgs']
598
+
599
+ if not orgs_to_list:
600
+ print("No orgs configured.")
601
+ print("Run 'tori config add <org-name>' to set up.")
602
+ return
603
+
604
+ for name, org_config in orgs_to_list.items():
605
+ count = len(org_config.get('cached_accounts', {}))
606
+ print(f" {name} — {count} account{'s' if count != 1 else ''}")
607
+
608
+
609
+ def open_console(self, profile: str = 'default', destination: Optional[str] = None):
610
+ """Open the AWS web console signed in with credentials from the given profile."""
611
+ import urllib.parse
612
+ import urllib.request
613
+
614
+ cp = configparser.ConfigParser()
615
+ if self.aws_credentials_path.exists():
616
+ cp.read(self.aws_credentials_path)
617
+
618
+ if profile not in cp or 'aws_session_token' not in cp[profile]:
619
+ print(f"No active session in profile '{profile}'. Run 'tori assume' first.")
620
+ return
621
+
622
+ # Resolve region: ~/.aws/config first, then last_assumed_role
623
+ region = None
624
+ cfg_cp = configparser.ConfigParser()
625
+ if self.aws_config_path.exists():
626
+ cfg_cp.read(self.aws_config_path)
627
+ cfg_section = 'default' if profile == 'default' else f'profile {profile}'
628
+ if cfg_section in cfg_cp and 'region' in cfg_cp[cfg_section]:
629
+ region = cfg_cp[cfg_section]['region']
630
+ elif self.config.get('last_assumed_role'):
631
+ region = self.config['last_assumed_role'].get('region')
632
+
633
+ if not destination:
634
+ destination = f"https://{region}.console.aws.amazon.com/" if region else "https://console.aws.amazon.com/"
635
+
636
+ session = {
637
+ 'sessionId': cp[profile]['aws_access_key_id'],
638
+ 'sessionKey': cp[profile]['aws_secret_access_key'],
639
+ 'sessionToken': cp[profile]['aws_session_token'],
640
+ }
641
+
642
+ federation = 'https://signin.aws.amazon.com/federation'
643
+ try:
644
+ req_url = (
645
+ f"{federation}?Action=getSigninToken"
646
+ f"&Session={urllib.parse.quote(json.dumps(session))}"
647
+ )
648
+ with urllib.request.urlopen(req_url, timeout=10) as resp:
649
+ signin_token = json.loads(resp.read())['SigninToken']
650
+ except Exception as e:
651
+ print(f"Failed to get signin token: {e}")
652
+ return
653
+
654
+ login_url = (
655
+ f"{federation}?Action=login"
656
+ f"&Issuer={urllib.parse.quote('tori')}"
657
+ f"&Destination={urllib.parse.quote(destination)}"
658
+ f"&SigninToken={urllib.parse.quote(signin_token)}"
659
+ )
660
+ print(f"Opening AWS console for profile '{profile}'...")
661
+ webbrowser.open(login_url)
662
+
663
+ def logout(self, clear_sso: bool = False):
664
+ """Clear the default AWS profile. Optionally invalidate cached SSO tokens."""
665
+ cleared_default = False
666
+ if self.aws_credentials_path.exists():
667
+ cp = configparser.ConfigParser()
668
+ cp.read(self.aws_credentials_path)
669
+ if 'default' in cp:
670
+ cp.remove_section('default')
671
+ with open(self.aws_credentials_path, 'w') as f:
672
+ cp.write(f)
673
+ cleared_default = True
674
+
675
+ self.config['last_assumed_role'] = None
676
+ self._save_config()
677
+
678
+ if cleared_default:
679
+ print("Cleared default AWS profile")
680
+ else:
681
+ print("No default profile to clear")
682
+
683
+ if clear_sso:
684
+ sso_cache_dir = Path.home() / '.aws' / 'sso' / 'cache'
685
+ removed = 0
686
+ if sso_cache_dir.exists():
687
+ for f in sso_cache_dir.glob('*.json'):
688
+ try:
689
+ f.unlink()
690
+ removed += 1
691
+ except OSError:
692
+ pass
693
+ print(f"Cleared {removed} cached SSO token(s)")
694
+
695
+ def switch_profile(self, profile_name: Optional[str] = None, target: str = 'default'):
696
+ """Switch a backed-up tori session into the given AWS profile (default: [default])."""
697
+ active_profiles = self.config.get('active_profiles', {})
698
+ if not active_profiles:
699
+ print("No backed-up profiles. Run 'tori assume' to create one.")
700
+ return
701
+
702
+ # Interactive picker if no name given
703
+ if not profile_name:
704
+ try:
705
+ import questionary
706
+ from tori.cli import tori_style
707
+ choices = []
708
+ label_to_name = {}
709
+ for name, info in active_profiles.items():
710
+ expires_at = info.get('expires_at')
711
+ suffix = ""
712
+ if expires_at:
713
+ mins = int((expires_at - time.time()) / 60)
714
+ suffix = f" ({mins}m left)"
715
+ label = f"{info.get('account_name') or info.get('account_id')} / {info.get('role_name')}{suffix}"
716
+ choices.append(label)
717
+ label_to_name[label] = name
718
+
719
+ selection = questionary.select(
720
+ 'Switch to which profile?',
721
+ choices=choices,
722
+ style=tori_style()
723
+ ).ask()
724
+ if not selection:
725
+ return
726
+ profile_name = label_to_name[selection]
727
+ except (KeyboardInterrupt, EOFError):
728
+ print("\nCancelled")
729
+ return
730
+
731
+ if profile_name not in active_profiles:
732
+ print(f"Profile '{profile_name}' not found.")
733
+ return
734
+
735
+ info = active_profiles[profile_name]
736
+
737
+ # Read backup from ~/.tori/credentials
738
+ tori_cp = configparser.ConfigParser()
739
+ if self.tori_credentials_path.exists():
740
+ tori_cp.read(self.tori_credentials_path)
741
+
742
+ if profile_name not in tori_cp:
743
+ print(f"Backup credentials for '{profile_name}' missing. Run 'tori assume' to fetch fresh credentials.")
744
+ return
745
+
746
+ # Write into [<target>] of ~/.aws/credentials
747
+ aws_cp = configparser.ConfigParser()
748
+ if self.aws_credentials_path.exists():
749
+ aws_cp.read(self.aws_credentials_path)
750
+ if target not in aws_cp:
751
+ aws_cp[target] = {}
752
+ for key in ['aws_access_key_id', 'aws_secret_access_key', 'aws_session_token']:
753
+ if key in tori_cp[profile_name]:
754
+ aws_cp[target][key] = tori_cp[profile_name][key]
755
+
756
+ with open(self.aws_credentials_path, 'w') as f:
757
+ aws_cp.write(f)
758
+
759
+ # Update region in ~/.aws/config (default → [default], else → [profile <name>])
760
+ if info.get('region'):
761
+ cp2 = configparser.ConfigParser()
762
+ if self.aws_config_path.exists():
763
+ cp2.read(self.aws_config_path)
764
+ cfg_section = 'default' if target == 'default' else f'profile {target}'
765
+ if cfg_section not in cp2:
766
+ cp2[cfg_section] = {}
767
+ cp2[cfg_section]['region'] = info['region']
768
+ with open(self.aws_config_path, 'w') as f:
769
+ cp2.write(f)
770
+
771
+ # Update last_assumed_role from the stored profile info
772
+ self.config['last_assumed_role'] = {
773
+ 'account_id': info.get('account_id'),
774
+ 'account_name': info.get('account_name'),
775
+ 'role_name': info.get('role_name'),
776
+ 'org_name': info.get('org_name'),
777
+ 'region': info.get('region'),
778
+ 'expires_at': info.get('expires_at'),
779
+ }
780
+ self._save_config()
781
+
782
+ print(f"Switched [{target}] to: {info.get('account_name') or info.get('account_id')} / {info.get('role_name')}")
783
+
784
+ def remove_org(self, org_name: str, confirm: bool = True):
785
+ """Remove an org from the config"""
786
+ if org_name not in self.config.get('orgs', {}):
787
+ print(f"Org '{org_name}' not found.")
788
+ return
789
+
790
+ if confirm:
791
+ try:
792
+ answer = input(f"Remove org '{org_name}'? This deletes the cached config. [y/N]: ").strip().lower()
793
+ except (KeyboardInterrupt, EOFError):
794
+ print("\nCancelled")
795
+ return
796
+ if answer not in ('y', 'yes'):
797
+ print("Cancelled")
798
+ return
799
+
800
+ del self.config['orgs'][org_name]
801
+ self._save_config()
802
+ print(f"Removed org '{org_name}'")
803
+
804
+ def show_status(self, verify: bool = False):
805
+ """Show AWS credentials status. Reads local state by default; pass verify=True to call STS."""
806
+ print("AWS Credentials Status")
807
+ print("=" * 50)
808
+
809
+ # Local read — instant
810
+ config_parser = configparser.ConfigParser()
811
+ if self.aws_credentials_path.exists():
812
+ config_parser.read(self.aws_credentials_path)
813
+
814
+ has_default = (
815
+ 'default' in config_parser
816
+ and 'aws_session_token' in config_parser['default']
817
+ )
818
+
819
+ last = self.config.get('last_assumed_role')
820
+ if has_default and last:
821
+ expires_at = last.get('expires_at')
822
+ now = time.time()
823
+ status_label = ""
824
+ remaining = ""
825
+ if expires_at:
826
+ if expires_at < now:
827
+ status_label = " [EXPIRED]"
828
+ else:
829
+ secs = int(expires_at - now)
830
+ h, rem = divmod(secs, 3600)
831
+ m = rem // 60
832
+ remaining = f" ({h}h {m}m left)"
833
+ if secs < 600:
834
+ status_label = " [EXPIRING SOON]"
835
+
836
+ account_label = last.get('account_name') or last.get('account_id')
837
+ print(f"Active session (default profile){status_label}")
838
+ print(f" Org: {last.get('org_name', 'n/a')}")
839
+ print(f" Account: {account_label} ({last.get('account_id')})")
840
+ print(f" Role: {last.get('role_name')}")
841
+ if last.get('region'):
842
+ print(f" Region: {last['region']}")
843
+ if expires_at:
844
+ expires_dt = datetime.fromtimestamp(expires_at)
845
+ print(f" Expires: {expires_dt.strftime('%Y-%m-%d %H:%M:%S')}{remaining}")
846
+ elif has_default:
847
+ print("Active session (default profile) — set outside tori")
848
+ else:
849
+ print("No active session")
850
+ print("Run 'tori assume <account-name-or-id>' to start a session")
851
+
852
+ active_profiles = self.config.get('active_profiles', {})
853
+ if active_profiles:
854
+ print(f"\nBacked up profiles:")
855
+ for profile_name, profile_info in active_profiles.items():
856
+ print(f" {profile_name}")
857
+ print(f" Account: {profile_info.get('account_id')}")
858
+ print(f" Role: {profile_info.get('role_name')}")
859
+
860
+ if verify and has_default:
861
+ print("\nVerifying with AWS STS...")
862
+ try:
863
+ import boto3
864
+ sts = boto3.client('sts')
865
+ identity = sts.get_caller_identity()
866
+ print(f" Verified ARN: {identity.get('Arn')}")
867
+ except Exception as e:
868
+ print(f" Verification failed: {e}")
869
+