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.
- ssof-0.1.0.dist-info/METADATA +232 -0
- ssof-0.1.0.dist-info/RECORD +7 -0
- ssof-0.1.0.dist-info/WHEEL +5 -0
- ssof-0.1.0.dist-info/entry_points.txt +2 -0
- ssof-0.1.0.dist-info/top_level.txt +1 -0
- tori/cli.py +215 -0
- tori/sso_manager.py +869 -0
|
@@ -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 @@
|
|
|
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
|
+
|