brolly 0.1.0__tar.gz
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.
- brolly-0.1.0/PKG-INFO +324 -0
- brolly-0.1.0/README.md +298 -0
- brolly-0.1.0/pyproject.toml +61 -0
- brolly-0.1.0/src/brolly/__init__.py +1 -0
- brolly-0.1.0/src/brolly/__main__.py +4 -0
- brolly-0.1.0/src/brolly/cli.py +371 -0
brolly-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: brolly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A pure-Python CLI for AWS IAM Identity Center (SSO)
|
|
5
|
+
Keywords: aws,sso,iam-identity-center,cli,boto3,aws-profile
|
|
6
|
+
Author: Full Duplex Media
|
|
7
|
+
Author-email: Full Duplex Media <contact@fullduplex.media>
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Operating System :: POSIX
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Classifier: Topic :: System :: Systems Administration :: Authentication/Directory
|
|
20
|
+
Requires-Dist: boto3
|
|
21
|
+
Requires-Python: >=3.14
|
|
22
|
+
Project-URL: Homepage, https://github.com/fduplex/brolly
|
|
23
|
+
Project-URL: Repository, https://github.com/fduplex/brolly
|
|
24
|
+
Project-URL: Issues, https://github.com/fduplex/brolly/issues
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# brolly
|
|
28
|
+
|
|
29
|
+
> *brolly* — British informal for umbrella. One login covers every profile under an AWS SSO session.
|
|
30
|
+
|
|
31
|
+
A small, pure-Python CLI for AWS IAM Identity Center (SSO): log in once per session, switch accounts and roles
|
|
32
|
+
in place, and keep your profiles fresh — without ever touching `$AWS_PROFILE`.
|
|
33
|
+
|
|
34
|
+
brolly drives AWS SSO the way the modern `[sso-session]` config was meant to be used: authenticate once against
|
|
35
|
+
a session and every profile that references it is usable. It refreshes and verifies credentials cheaply (the
|
|
36
|
+
default), forces a fresh login when you want one, repoints the current profile to a different account/role, or
|
|
37
|
+
adds a new profile under an existing session — and it ships a freshness-aware shell-prompt pill so you can see
|
|
38
|
+
your credential state at a glance.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
brolly verify/refresh the current profile (same as `brolly refresh`)
|
|
42
|
+
brolly login [-s <session>] force a fresh device-code login for a session
|
|
43
|
+
brolly switch repoint the current profile's account/role
|
|
44
|
+
brolly refresh [<profile>] [-s <session>]
|
|
45
|
+
brolly add <profile> [-s <session>]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```console
|
|
51
|
+
$ uv tool install brolly # recommended
|
|
52
|
+
$ pipx install brolly
|
|
53
|
+
$ pip install brolly
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Prerequisites:**
|
|
57
|
+
|
|
58
|
+
- **AWS CLI v2** on your `$PATH`. brolly shells out to `aws sso login` and `aws configure set`; everything else
|
|
59
|
+
(listing accounts/roles, resolving tokens) goes through boto3. The AWS CLI is *not* a pip dependency — install
|
|
60
|
+
it separately: <https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html>.
|
|
61
|
+
- **Python 3.14+**.
|
|
62
|
+
- **A Unix-like terminal.** The arrow-key picker uses `termios`/`tty`, so brolly's interactive menus need a
|
|
63
|
+
POSIX TTY (Linux, macOS, WSL). Off a TTY it falls back to numeric selection.
|
|
64
|
+
|
|
65
|
+
## Mental model
|
|
66
|
+
|
|
67
|
+
Modern AWS SSO config in `~/.aws/config` has two layers:
|
|
68
|
+
|
|
69
|
+
- **`[sso-session <name>]`** — the session. Holds `sso_start_url`, `sso_region`, etc. Logging in against
|
|
70
|
+
a session caches ONE token, keyed by SHA1 of the session name (`~/.aws/sso/cache/<sha1>.json`).
|
|
71
|
+
- **`[profile <name>]`** — a profile references a session via `sso_session = <name>` and adds its own
|
|
72
|
+
`sso_account_id` / `sso_role_name`. Any number of profiles can point at the same session.
|
|
73
|
+
|
|
74
|
+
Because the token is cached per-session, not per-profile, **every profile under the same session shares one
|
|
75
|
+
login** — authenticate once and all sibling profiles are usable. (One login covering many profiles is where the
|
|
76
|
+
name comes from.)
|
|
77
|
+
|
|
78
|
+
`$AWS_PROFILE` is a fixed handle you set yourself in each shell. **brolly never touches it** — it only changes
|
|
79
|
+
what a profile resolves to (`switch`), creates new profiles (`add`), or verifies a profile's credentials
|
|
80
|
+
(`refresh`, which targets the `aws` CLI with `--profile` rather than mutating the ambient env). To actually use
|
|
81
|
+
a different profile you still `export AWS_PROFILE=<name>` yourself. There is no shell wrapper and nothing that
|
|
82
|
+
rewrites your environment behind your back.
|
|
83
|
+
|
|
84
|
+
## Commands
|
|
85
|
+
|
|
86
|
+
### `brolly`
|
|
87
|
+
|
|
88
|
+
Bare `brolly` (no subcommand) is shorthand for `brolly refresh` on the current `$AWS_PROFILE` — cheap, no
|
|
89
|
+
browser unless the session is actually dead.
|
|
90
|
+
|
|
91
|
+
```console
|
|
92
|
+
$ brolly
|
|
93
|
+
✔ corp-dev live → arn:aws:sts::111111111111:assumed-role/AdministratorAccess/alex
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `brolly login`
|
|
97
|
+
|
|
98
|
+
Forces a fresh device-code login for an sso-session, unconditionally — no token check first. Session
|
|
99
|
+
defaults to the current `$AWS_PROFILE`'s `sso_session`; `-s/--session` targets a different session. It's
|
|
100
|
+
session-scoped, not profile-scoped (no profile argument).
|
|
101
|
+
|
|
102
|
+
This is the rare escape hatch for "I'm still valid but I want a new login anyway" — `refresh` (and therefore
|
|
103
|
+
bare `brolly`) already logs in automatically whenever a session is actually dead, so day to day you shouldn't
|
|
104
|
+
need this.
|
|
105
|
+
|
|
106
|
+
```console
|
|
107
|
+
$ brolly login
|
|
108
|
+
$ brolly login -s corp
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### `brolly switch`
|
|
112
|
+
|
|
113
|
+
Interactively repoints the CURRENT `$AWS_PROFILE` to a different account/role under its own session. Arrow-key
|
|
114
|
+
picker (`↑`/`↓` or `j`/`k`, `enter` to select, `q`/`esc`/Ctrl-C to quit); shows the account list, then the role
|
|
115
|
+
list (skipped if the account has exactly one role). Current account/role are pre-selected. `sso_account_id`,
|
|
116
|
+
`sso_role_name`, and `sso_account_name` of that profile are rewritten in place — everything else about the
|
|
117
|
+
profile is untouched. Recording `sso_account_name` is what lets the shell prompt show a friendly account name
|
|
118
|
+
instead of the raw ID.
|
|
119
|
+
|
|
120
|
+
```console
|
|
121
|
+
$ export AWS_PROFILE=corp-prod
|
|
122
|
+
$ brolly switch
|
|
123
|
+
select account ↑/↓ move · enter select · q quit
|
|
124
|
+
ACCOUNT NAME
|
|
125
|
+
111111111111 corp-prod ← current
|
|
126
|
+
▶ 222222222222 corp-staging
|
|
127
|
+
|
|
128
|
+
select role ↑/↓ move · enter select · q quit
|
|
129
|
+
ROLE
|
|
130
|
+
▶ AdministratorAccess
|
|
131
|
+
|
|
132
|
+
✔ corp-prod → 222222222222 (corp-staging) / AdministratorAccess
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### `brolly refresh`
|
|
136
|
+
|
|
137
|
+
The cheap, no-browser daily-driver check. Takes an optional `<profile>` positional (default: `$AWS_PROFILE`)
|
|
138
|
+
and an optional `-s/--session <name>` that asserts which sso-session you're operating in (default: the
|
|
139
|
+
`sso_session` of the current `$AWS_PROFILE`). The target profile must actually belong to the asserted session,
|
|
140
|
+
or the command fails loudly — this makes crossing sessions always deliberate:
|
|
141
|
+
|
|
142
|
+
```console
|
|
143
|
+
$ AWS_PROFILE=corp-dev brolly refresh corp-prod
|
|
144
|
+
✔ corp-prod live → arn:aws:sts::222222222222:assumed-role/AdministratorAccess/alex
|
|
145
|
+
|
|
146
|
+
$ AWS_PROFILE=customer-admin brolly refresh corp-prod
|
|
147
|
+
profile 'corp-prod' is under session 'corp', not 'customer' — use -s corp to target it
|
|
148
|
+
|
|
149
|
+
$ AWS_PROFILE=customer-admin brolly refresh corp-prod -s corp
|
|
150
|
+
✔ corp-prod live → arn:aws:sts::222222222222:assumed-role/AdministratorAccess/alex
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
None of this ever touches `$AWS_PROFILE`: `refresh` runs `aws sts get-caller-identity --profile <target>` —
|
|
154
|
+
overriding the ambient env var for just that one call — and logs in with `--sso-session <session>` if needed.
|
|
155
|
+
Your shell's `$AWS_PROFILE` is exactly what it was before; that's the whole point of `-s` — it lets you check on
|
|
156
|
+
(or log into) a profile in another session without poisoning your shell.
|
|
157
|
+
|
|
158
|
+
Under the hood: `get-caller-identity` forces credential resolution and lets botocore refresh the hourly SSO
|
|
159
|
+
token as a side effect — but only when the token is already lapsed or within ~15 min of expiry (botocore's own
|
|
160
|
+
refresh window). If it still has plenty of time left, `refresh` just confirms you're authenticated without
|
|
161
|
+
resetting the clock — that's the intended cheap behavior. Contrast with `brolly login`, which always does a full
|
|
162
|
+
`aws sso login` unconditionally.
|
|
163
|
+
|
|
164
|
+
If credentials can't be resolved at all (the 7-day session is dead), it prints a notice, falls through to a
|
|
165
|
+
device-code `aws sso login`, and retries. It also opportunistically backfills `sso_account_name` on the target
|
|
166
|
+
profile if that key is missing (one `list_accounts` call, made only when absent) — so an existing profile's
|
|
167
|
+
prompt name heals itself the first time you refresh it.
|
|
168
|
+
|
|
169
|
+
### `brolly add <profile> [-s <session>]`
|
|
170
|
+
|
|
171
|
+
Creates a NEW profile under an existing sso-session, walks the same account/role picker, and leaves it
|
|
172
|
+
authenticated and ready to use. `-s/--session` picks the session (default: the `sso_session` of the current
|
|
173
|
+
`$AWS_PROFILE`) — there's no cross-session guard here like `refresh` has, since the profile being written is
|
|
174
|
+
new:
|
|
175
|
+
|
|
176
|
+
```console
|
|
177
|
+
$ brolly add corp-qa # session inferred from current $AWS_PROFILE's sso_session
|
|
178
|
+
$ brolly add customer-admin -s customer # explicit session
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
What it does:
|
|
182
|
+
|
|
183
|
+
1. Refuses if `<profile>` already exists (tells you to use `brolly switch` instead), or if `<session>` isn't a
|
|
184
|
+
known `sso-session` (lists the available ones).
|
|
185
|
+
2. Writes a profile skeleton: `sso_session = <session>`, plus `region`/`output` copied from a sibling profile on
|
|
186
|
+
the same session if one exists, otherwise the session's `sso_region` and `json`.
|
|
187
|
+
3. Ensures a valid token for the session, logging in if needed.
|
|
188
|
+
4. Runs the account/role picker, then writes `sso_account_id` / `sso_role_name` / `sso_account_name`.
|
|
189
|
+
|
|
190
|
+
It does **not** change `$AWS_PROFILE`. To use the new profile: `export AWS_PROFILE=<name>`.
|
|
191
|
+
|
|
192
|
+
**Recovery:** if you Ctrl-C out of the picker mid-`add`, the profile skeleton (step 2) is already written to
|
|
193
|
+
`~/.aws/config`, so re-running `brolly add` will hit the "already exists" guard. Finish it instead:
|
|
194
|
+
|
|
195
|
+
```console
|
|
196
|
+
$ export AWS_PROFILE=new-account
|
|
197
|
+
$ brolly switch # or `brolly` first if the token also expired
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Common tasks
|
|
201
|
+
|
|
202
|
+
| Situation | Command |
|
|
203
|
+
|---|---|
|
|
204
|
+
| Verify/refresh current creds | `brolly` |
|
|
205
|
+
| Force a fresh login (session healthy but you want a new one) | `brolly login` |
|
|
206
|
+
| Wrong account or role for the current profile | `brolly switch` |
|
|
207
|
+
| Need a new profile under the current session | `brolly add <name>` then `export AWS_PROFILE=<name>` |
|
|
208
|
+
| Need a new profile under a different session, e.g. `customer` | `brolly add <name> -s customer` |
|
|
209
|
+
| Refresh a profile in a different session from this shell | `brolly refresh <profile> -s <session>` |
|
|
210
|
+
| Interrupted a `brolly add` mid-picker | `export AWS_PROFILE=<name>` then `brolly switch` |
|
|
211
|
+
|
|
212
|
+
## Shell prompt integration
|
|
213
|
+
|
|
214
|
+
brolly ships a drop-in `__aws_ps1` function for your `~/.bashrc` that renders a colored `AWS_PROFILE` pill
|
|
215
|
+
reflecting the **local, filesystem-only** state of the session token — no network call, no `aws`/boto3
|
|
216
|
+
invocation on every prompt:
|
|
217
|
+
|
|
218
|
+
- **live** (bright orange) — hourly token still valid.
|
|
219
|
+
- **idle** (grey, clock glyph) — cached but lapsed; refreshes automatically on next use.
|
|
220
|
+
- **gone** (red, cross glyph) — no cached token; run `brolly`.
|
|
221
|
+
- **plain** (neutral grey) — profile has no `sso_session` (not an SSO profile).
|
|
222
|
+
|
|
223
|
+
A dead 7-day session can't be detected locally, so it still reads as `idle` rather than `gone`. The pill also
|
|
224
|
+
shows the account: `<profile> · <account>`, where `<account>` is the friendly `sso_account_name` when the
|
|
225
|
+
profile has one, falling back to the raw `sso_account_id` if not. Names get populated by `switch`, `add`, and
|
|
226
|
+
`refresh`; until one of those runs against a given profile, the pill just falls back to the raw account ID.
|
|
227
|
+
|
|
228
|
+
Drop this into your `~/.bashrc` and add `$(__aws_ps1)` to your `PS1`. It needs a **[Nerd Font](https://www.nerdfonts.com/)**
|
|
229
|
+
for the powerline separators and glyphs.
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
__aws_ps1() {
|
|
233
|
+
[[ -z $AWS_PROFILE ]] && return
|
|
234
|
+
|
|
235
|
+
# Cheap, filesystem-only freshness check — no aws/boto3 call, no network.
|
|
236
|
+
# live = hourly token still valid · idle = cached but lapsed (refreshes on next use) · gone = no token, must log in.
|
|
237
|
+
# The 7-day session's true death can't be known locally, so a dead session reads as "idle", not "gone".
|
|
238
|
+
local cfg="${AWS_CONFIG_FILE:-$HOME/.aws/config}"
|
|
239
|
+
local session acct cache exp now state sbg sfg glyph
|
|
240
|
+
IFS=$'\t' read -r session acct < <(awk -v h="[profile $AWS_PROFILE]" '
|
|
241
|
+
$0==h {i=1; next}
|
|
242
|
+
/^\[/ {i=0}
|
|
243
|
+
i && /^[[:space:]]*sso_session[[:space:]]*=/ {v=$0; sub(/^[^=]*=[[:space:]]*/,"",v); s=v}
|
|
244
|
+
i && /^[[:space:]]*sso_account_name[[:space:]]*=/ {v=$0; sub(/^[^=]*=[[:space:]]*/,"",v); n=v}
|
|
245
|
+
i && /^[[:space:]]*sso_account_id[[:space:]]*=/ {v=$0; sub(/^[^=]*=[[:space:]]*/,"",v); a=v}
|
|
246
|
+
END {print s "\t" (n!=""?n:a)}' "$cfg" 2>/dev/null)
|
|
247
|
+
|
|
248
|
+
if [[ -z $session ]]; then
|
|
249
|
+
state=plain
|
|
250
|
+
else
|
|
251
|
+
cache="$HOME/.aws/sso/cache/$(printf %s "$session" | sha1sum | cut -c1-40).json"
|
|
252
|
+
if [[ ! -f $cache ]]; then
|
|
253
|
+
state=gone
|
|
254
|
+
else
|
|
255
|
+
exp=$(sed -n 's/.*"expiresAt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$cache" | head -1)
|
|
256
|
+
printf -v now '%(%s)T' -1
|
|
257
|
+
exp=$(date -d "$exp" +%s 2>/dev/null)
|
|
258
|
+
[[ -n $exp && $exp -gt $now ]] && state=live || state=idle
|
|
259
|
+
fi
|
|
260
|
+
fi
|
|
261
|
+
|
|
262
|
+
case $state in
|
|
263
|
+
live) sbg=214 sfg=236 glyph='' ;; # bright AWS orange — creds are live
|
|
264
|
+
idle) sbg=240 sfg=214 glyph=$' ' ;; # grey clock — lapsed, will refresh on next use
|
|
265
|
+
gone) sbg=160 sfg=231 glyph=$' ' ;; # red cross — no cached token, run brolly
|
|
266
|
+
plain) sbg=238 sfg=250 glyph='' ;; # non-SSO profile — neutral
|
|
267
|
+
esac
|
|
268
|
+
|
|
269
|
+
# powerline pill: amazon-glyph | profile ; \001/\002 wrap non-printing bytes for correct prompt-width math
|
|
270
|
+
local E=$'\033' A=$'\001' B=$'\002' PL=$'' AWS=$''
|
|
271
|
+
printf '%s%s%s%s%s' \
|
|
272
|
+
"${A}${E}[48;5;238;38;5;214;1m${B} ${AWS} " \
|
|
273
|
+
"${A}${E}[38;5;238;48;5;${sbg}m${B}${PL}" \
|
|
274
|
+
"${A}${E}[48;5;${sbg};38;5;${sfg};1m${B} ${glyph}${AWS_PROFILE}${acct:+ · $acct} " \
|
|
275
|
+
"${A}${E}[0;38;5;${sbg}m${B}${PL}" \
|
|
276
|
+
"${A}${E}[0m${B} "
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## How it compares
|
|
281
|
+
|
|
282
|
+
No single existing tool combines what brolly does. Its niche is the *combination*: native to the `sso-session` block
|
|
283
|
+
+ in-place `switch`/`add` + never mutating `$AWS_PROFILE` + a shipped freshness-aware prompt pill +
|
|
284
|
+
pure-Python/pip.
|
|
285
|
+
|
|
286
|
+
- **[aws-sso-util](https://github.com/benkehoe/aws-sso-util)** (Ben Kehoe) is the closest sibling — pure-Python,
|
|
287
|
+
config-native, and non-invasive, in the same spirit as brolly. But it has no prompt integration, no in-place
|
|
288
|
+
single-profile repoint, and predates the `sso-session` block.
|
|
289
|
+
- **[granted](https://granted.dev/)** and **[aws-sso-cli](https://github.com/synfinatic/aws-sso-cli)** are
|
|
290
|
+
excellent tools, but they're Go binaries that install a shell wrapper mutating your shell and generate bulk
|
|
291
|
+
static profiles. brolly is deliberately thinner: no wrapper, no bulk generation, in-place edits only.
|
|
292
|
+
- **[awsume](https://awsu.me/)** targets classic IAM role assumption, not Identity Center.
|
|
293
|
+
|
|
294
|
+
This is positioning, not disparagement — if you want OS-keychain-encrypted tokens today, reach for aws-vault,
|
|
295
|
+
granted, or aws-sso-cli (see caveats below and the roadmap).
|
|
296
|
+
|
|
297
|
+
## Design notes & caveats
|
|
298
|
+
|
|
299
|
+
Two things to own up front:
|
|
300
|
+
|
|
301
|
+
- **`switch` rewrites `~/.aws/config` globally.** A profile lives in one shared config file, so switching the
|
|
302
|
+
account/role of a profile name silently retargets *any other shell* pinned to the **same profile name** on its
|
|
303
|
+
next command. Safe use = **one distinct profile name per concurrent context.** If you keep two shells on the
|
|
304
|
+
same account simultaneously, give them different profile names. This is the one real footgun — stated plainly.
|
|
305
|
+
- **Tokens live in the stock plaintext `~/.aws/sso/cache/`** — the very same cache the `aws` CLI uses. brolly
|
|
306
|
+
adds no encryption of its own; it's a thin layer over the stock cache by design. For OS-keychain encryption
|
|
307
|
+
*today*, use aws-vault / granted / aws-sso-cli. Encrypted storage is the lead roadmap item below.
|
|
308
|
+
|
|
309
|
+
## Roadmap
|
|
310
|
+
|
|
311
|
+
1. **Keychain-backed token storage via a `credential_process` mode.** An opt-in mode where brolly does the
|
|
312
|
+
device auth itself, stores the SSO token in the OS keychain (via the Python
|
|
313
|
+
[`keyring`](https://github.com/jaraco/keyring) library), and registers itself as each profile's
|
|
314
|
+
`credential_process` — so credentials never sit in plaintext on disk and SDK consumers stay fully transparent
|
|
315
|
+
(just `AWS_PROFILE`, no shell wrapper). The prompt pill's freshness check would then read a small non-secret
|
|
316
|
+
expiry sidecar instead of the plaintext cache. On headless Linux with no OS keychain, `keyring` falls back to
|
|
317
|
+
an encrypted-file backend guarded by a passphrase. This mode makes brolly "take over credential resolution,"
|
|
318
|
+
which is exactly the trade the plaintext default avoids — so it stays **opt-in**, keeping brolly a thin stock-
|
|
319
|
+
cache layer by default.
|
|
320
|
+
2. Test suite, CI, and publish to the `fduplex` PyPI org.
|
|
321
|
+
|
|
322
|
+
## License
|
|
323
|
+
|
|
324
|
+
[Apache-2.0](LICENSE) © 2026 Full Duplex Media
|
brolly-0.1.0/README.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# brolly
|
|
2
|
+
|
|
3
|
+
> *brolly* — British informal for umbrella. One login covers every profile under an AWS SSO session.
|
|
4
|
+
|
|
5
|
+
A small, pure-Python CLI for AWS IAM Identity Center (SSO): log in once per session, switch accounts and roles
|
|
6
|
+
in place, and keep your profiles fresh — without ever touching `$AWS_PROFILE`.
|
|
7
|
+
|
|
8
|
+
brolly drives AWS SSO the way the modern `[sso-session]` config was meant to be used: authenticate once against
|
|
9
|
+
a session and every profile that references it is usable. It refreshes and verifies credentials cheaply (the
|
|
10
|
+
default), forces a fresh login when you want one, repoints the current profile to a different account/role, or
|
|
11
|
+
adds a new profile under an existing session — and it ships a freshness-aware shell-prompt pill so you can see
|
|
12
|
+
your credential state at a glance.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
brolly verify/refresh the current profile (same as `brolly refresh`)
|
|
16
|
+
brolly login [-s <session>] force a fresh device-code login for a session
|
|
17
|
+
brolly switch repoint the current profile's account/role
|
|
18
|
+
brolly refresh [<profile>] [-s <session>]
|
|
19
|
+
brolly add <profile> [-s <session>]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```console
|
|
25
|
+
$ uv tool install brolly # recommended
|
|
26
|
+
$ pipx install brolly
|
|
27
|
+
$ pip install brolly
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Prerequisites:**
|
|
31
|
+
|
|
32
|
+
- **AWS CLI v2** on your `$PATH`. brolly shells out to `aws sso login` and `aws configure set`; everything else
|
|
33
|
+
(listing accounts/roles, resolving tokens) goes through boto3. The AWS CLI is *not* a pip dependency — install
|
|
34
|
+
it separately: <https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html>.
|
|
35
|
+
- **Python 3.14+**.
|
|
36
|
+
- **A Unix-like terminal.** The arrow-key picker uses `termios`/`tty`, so brolly's interactive menus need a
|
|
37
|
+
POSIX TTY (Linux, macOS, WSL). Off a TTY it falls back to numeric selection.
|
|
38
|
+
|
|
39
|
+
## Mental model
|
|
40
|
+
|
|
41
|
+
Modern AWS SSO config in `~/.aws/config` has two layers:
|
|
42
|
+
|
|
43
|
+
- **`[sso-session <name>]`** — the session. Holds `sso_start_url`, `sso_region`, etc. Logging in against
|
|
44
|
+
a session caches ONE token, keyed by SHA1 of the session name (`~/.aws/sso/cache/<sha1>.json`).
|
|
45
|
+
- **`[profile <name>]`** — a profile references a session via `sso_session = <name>` and adds its own
|
|
46
|
+
`sso_account_id` / `sso_role_name`. Any number of profiles can point at the same session.
|
|
47
|
+
|
|
48
|
+
Because the token is cached per-session, not per-profile, **every profile under the same session shares one
|
|
49
|
+
login** — authenticate once and all sibling profiles are usable. (One login covering many profiles is where the
|
|
50
|
+
name comes from.)
|
|
51
|
+
|
|
52
|
+
`$AWS_PROFILE` is a fixed handle you set yourself in each shell. **brolly never touches it** — it only changes
|
|
53
|
+
what a profile resolves to (`switch`), creates new profiles (`add`), or verifies a profile's credentials
|
|
54
|
+
(`refresh`, which targets the `aws` CLI with `--profile` rather than mutating the ambient env). To actually use
|
|
55
|
+
a different profile you still `export AWS_PROFILE=<name>` yourself. There is no shell wrapper and nothing that
|
|
56
|
+
rewrites your environment behind your back.
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
|
|
60
|
+
### `brolly`
|
|
61
|
+
|
|
62
|
+
Bare `brolly` (no subcommand) is shorthand for `brolly refresh` on the current `$AWS_PROFILE` — cheap, no
|
|
63
|
+
browser unless the session is actually dead.
|
|
64
|
+
|
|
65
|
+
```console
|
|
66
|
+
$ brolly
|
|
67
|
+
✔ corp-dev live → arn:aws:sts::111111111111:assumed-role/AdministratorAccess/alex
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### `brolly login`
|
|
71
|
+
|
|
72
|
+
Forces a fresh device-code login for an sso-session, unconditionally — no token check first. Session
|
|
73
|
+
defaults to the current `$AWS_PROFILE`'s `sso_session`; `-s/--session` targets a different session. It's
|
|
74
|
+
session-scoped, not profile-scoped (no profile argument).
|
|
75
|
+
|
|
76
|
+
This is the rare escape hatch for "I'm still valid but I want a new login anyway" — `refresh` (and therefore
|
|
77
|
+
bare `brolly`) already logs in automatically whenever a session is actually dead, so day to day you shouldn't
|
|
78
|
+
need this.
|
|
79
|
+
|
|
80
|
+
```console
|
|
81
|
+
$ brolly login
|
|
82
|
+
$ brolly login -s corp
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### `brolly switch`
|
|
86
|
+
|
|
87
|
+
Interactively repoints the CURRENT `$AWS_PROFILE` to a different account/role under its own session. Arrow-key
|
|
88
|
+
picker (`↑`/`↓` or `j`/`k`, `enter` to select, `q`/`esc`/Ctrl-C to quit); shows the account list, then the role
|
|
89
|
+
list (skipped if the account has exactly one role). Current account/role are pre-selected. `sso_account_id`,
|
|
90
|
+
`sso_role_name`, and `sso_account_name` of that profile are rewritten in place — everything else about the
|
|
91
|
+
profile is untouched. Recording `sso_account_name` is what lets the shell prompt show a friendly account name
|
|
92
|
+
instead of the raw ID.
|
|
93
|
+
|
|
94
|
+
```console
|
|
95
|
+
$ export AWS_PROFILE=corp-prod
|
|
96
|
+
$ brolly switch
|
|
97
|
+
select account ↑/↓ move · enter select · q quit
|
|
98
|
+
ACCOUNT NAME
|
|
99
|
+
111111111111 corp-prod ← current
|
|
100
|
+
▶ 222222222222 corp-staging
|
|
101
|
+
|
|
102
|
+
select role ↑/↓ move · enter select · q quit
|
|
103
|
+
ROLE
|
|
104
|
+
▶ AdministratorAccess
|
|
105
|
+
|
|
106
|
+
✔ corp-prod → 222222222222 (corp-staging) / AdministratorAccess
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### `brolly refresh`
|
|
110
|
+
|
|
111
|
+
The cheap, no-browser daily-driver check. Takes an optional `<profile>` positional (default: `$AWS_PROFILE`)
|
|
112
|
+
and an optional `-s/--session <name>` that asserts which sso-session you're operating in (default: the
|
|
113
|
+
`sso_session` of the current `$AWS_PROFILE`). The target profile must actually belong to the asserted session,
|
|
114
|
+
or the command fails loudly — this makes crossing sessions always deliberate:
|
|
115
|
+
|
|
116
|
+
```console
|
|
117
|
+
$ AWS_PROFILE=corp-dev brolly refresh corp-prod
|
|
118
|
+
✔ corp-prod live → arn:aws:sts::222222222222:assumed-role/AdministratorAccess/alex
|
|
119
|
+
|
|
120
|
+
$ AWS_PROFILE=customer-admin brolly refresh corp-prod
|
|
121
|
+
profile 'corp-prod' is under session 'corp', not 'customer' — use -s corp to target it
|
|
122
|
+
|
|
123
|
+
$ AWS_PROFILE=customer-admin brolly refresh corp-prod -s corp
|
|
124
|
+
✔ corp-prod live → arn:aws:sts::222222222222:assumed-role/AdministratorAccess/alex
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
None of this ever touches `$AWS_PROFILE`: `refresh` runs `aws sts get-caller-identity --profile <target>` —
|
|
128
|
+
overriding the ambient env var for just that one call — and logs in with `--sso-session <session>` if needed.
|
|
129
|
+
Your shell's `$AWS_PROFILE` is exactly what it was before; that's the whole point of `-s` — it lets you check on
|
|
130
|
+
(or log into) a profile in another session without poisoning your shell.
|
|
131
|
+
|
|
132
|
+
Under the hood: `get-caller-identity` forces credential resolution and lets botocore refresh the hourly SSO
|
|
133
|
+
token as a side effect — but only when the token is already lapsed or within ~15 min of expiry (botocore's own
|
|
134
|
+
refresh window). If it still has plenty of time left, `refresh` just confirms you're authenticated without
|
|
135
|
+
resetting the clock — that's the intended cheap behavior. Contrast with `brolly login`, which always does a full
|
|
136
|
+
`aws sso login` unconditionally.
|
|
137
|
+
|
|
138
|
+
If credentials can't be resolved at all (the 7-day session is dead), it prints a notice, falls through to a
|
|
139
|
+
device-code `aws sso login`, and retries. It also opportunistically backfills `sso_account_name` on the target
|
|
140
|
+
profile if that key is missing (one `list_accounts` call, made only when absent) — so an existing profile's
|
|
141
|
+
prompt name heals itself the first time you refresh it.
|
|
142
|
+
|
|
143
|
+
### `brolly add <profile> [-s <session>]`
|
|
144
|
+
|
|
145
|
+
Creates a NEW profile under an existing sso-session, walks the same account/role picker, and leaves it
|
|
146
|
+
authenticated and ready to use. `-s/--session` picks the session (default: the `sso_session` of the current
|
|
147
|
+
`$AWS_PROFILE`) — there's no cross-session guard here like `refresh` has, since the profile being written is
|
|
148
|
+
new:
|
|
149
|
+
|
|
150
|
+
```console
|
|
151
|
+
$ brolly add corp-qa # session inferred from current $AWS_PROFILE's sso_session
|
|
152
|
+
$ brolly add customer-admin -s customer # explicit session
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
What it does:
|
|
156
|
+
|
|
157
|
+
1. Refuses if `<profile>` already exists (tells you to use `brolly switch` instead), or if `<session>` isn't a
|
|
158
|
+
known `sso-session` (lists the available ones).
|
|
159
|
+
2. Writes a profile skeleton: `sso_session = <session>`, plus `region`/`output` copied from a sibling profile on
|
|
160
|
+
the same session if one exists, otherwise the session's `sso_region` and `json`.
|
|
161
|
+
3. Ensures a valid token for the session, logging in if needed.
|
|
162
|
+
4. Runs the account/role picker, then writes `sso_account_id` / `sso_role_name` / `sso_account_name`.
|
|
163
|
+
|
|
164
|
+
It does **not** change `$AWS_PROFILE`. To use the new profile: `export AWS_PROFILE=<name>`.
|
|
165
|
+
|
|
166
|
+
**Recovery:** if you Ctrl-C out of the picker mid-`add`, the profile skeleton (step 2) is already written to
|
|
167
|
+
`~/.aws/config`, so re-running `brolly add` will hit the "already exists" guard. Finish it instead:
|
|
168
|
+
|
|
169
|
+
```console
|
|
170
|
+
$ export AWS_PROFILE=new-account
|
|
171
|
+
$ brolly switch # or `brolly` first if the token also expired
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Common tasks
|
|
175
|
+
|
|
176
|
+
| Situation | Command |
|
|
177
|
+
|---|---|
|
|
178
|
+
| Verify/refresh current creds | `brolly` |
|
|
179
|
+
| Force a fresh login (session healthy but you want a new one) | `brolly login` |
|
|
180
|
+
| Wrong account or role for the current profile | `brolly switch` |
|
|
181
|
+
| Need a new profile under the current session | `brolly add <name>` then `export AWS_PROFILE=<name>` |
|
|
182
|
+
| Need a new profile under a different session, e.g. `customer` | `brolly add <name> -s customer` |
|
|
183
|
+
| Refresh a profile in a different session from this shell | `brolly refresh <profile> -s <session>` |
|
|
184
|
+
| Interrupted a `brolly add` mid-picker | `export AWS_PROFILE=<name>` then `brolly switch` |
|
|
185
|
+
|
|
186
|
+
## Shell prompt integration
|
|
187
|
+
|
|
188
|
+
brolly ships a drop-in `__aws_ps1` function for your `~/.bashrc` that renders a colored `AWS_PROFILE` pill
|
|
189
|
+
reflecting the **local, filesystem-only** state of the session token — no network call, no `aws`/boto3
|
|
190
|
+
invocation on every prompt:
|
|
191
|
+
|
|
192
|
+
- **live** (bright orange) — hourly token still valid.
|
|
193
|
+
- **idle** (grey, clock glyph) — cached but lapsed; refreshes automatically on next use.
|
|
194
|
+
- **gone** (red, cross glyph) — no cached token; run `brolly`.
|
|
195
|
+
- **plain** (neutral grey) — profile has no `sso_session` (not an SSO profile).
|
|
196
|
+
|
|
197
|
+
A dead 7-day session can't be detected locally, so it still reads as `idle` rather than `gone`. The pill also
|
|
198
|
+
shows the account: `<profile> · <account>`, where `<account>` is the friendly `sso_account_name` when the
|
|
199
|
+
profile has one, falling back to the raw `sso_account_id` if not. Names get populated by `switch`, `add`, and
|
|
200
|
+
`refresh`; until one of those runs against a given profile, the pill just falls back to the raw account ID.
|
|
201
|
+
|
|
202
|
+
Drop this into your `~/.bashrc` and add `$(__aws_ps1)` to your `PS1`. It needs a **[Nerd Font](https://www.nerdfonts.com/)**
|
|
203
|
+
for the powerline separators and glyphs.
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
__aws_ps1() {
|
|
207
|
+
[[ -z $AWS_PROFILE ]] && return
|
|
208
|
+
|
|
209
|
+
# Cheap, filesystem-only freshness check — no aws/boto3 call, no network.
|
|
210
|
+
# live = hourly token still valid · idle = cached but lapsed (refreshes on next use) · gone = no token, must log in.
|
|
211
|
+
# The 7-day session's true death can't be known locally, so a dead session reads as "idle", not "gone".
|
|
212
|
+
local cfg="${AWS_CONFIG_FILE:-$HOME/.aws/config}"
|
|
213
|
+
local session acct cache exp now state sbg sfg glyph
|
|
214
|
+
IFS=$'\t' read -r session acct < <(awk -v h="[profile $AWS_PROFILE]" '
|
|
215
|
+
$0==h {i=1; next}
|
|
216
|
+
/^\[/ {i=0}
|
|
217
|
+
i && /^[[:space:]]*sso_session[[:space:]]*=/ {v=$0; sub(/^[^=]*=[[:space:]]*/,"",v); s=v}
|
|
218
|
+
i && /^[[:space:]]*sso_account_name[[:space:]]*=/ {v=$0; sub(/^[^=]*=[[:space:]]*/,"",v); n=v}
|
|
219
|
+
i && /^[[:space:]]*sso_account_id[[:space:]]*=/ {v=$0; sub(/^[^=]*=[[:space:]]*/,"",v); a=v}
|
|
220
|
+
END {print s "\t" (n!=""?n:a)}' "$cfg" 2>/dev/null)
|
|
221
|
+
|
|
222
|
+
if [[ -z $session ]]; then
|
|
223
|
+
state=plain
|
|
224
|
+
else
|
|
225
|
+
cache="$HOME/.aws/sso/cache/$(printf %s "$session" | sha1sum | cut -c1-40).json"
|
|
226
|
+
if [[ ! -f $cache ]]; then
|
|
227
|
+
state=gone
|
|
228
|
+
else
|
|
229
|
+
exp=$(sed -n 's/.*"expiresAt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$cache" | head -1)
|
|
230
|
+
printf -v now '%(%s)T' -1
|
|
231
|
+
exp=$(date -d "$exp" +%s 2>/dev/null)
|
|
232
|
+
[[ -n $exp && $exp -gt $now ]] && state=live || state=idle
|
|
233
|
+
fi
|
|
234
|
+
fi
|
|
235
|
+
|
|
236
|
+
case $state in
|
|
237
|
+
live) sbg=214 sfg=236 glyph='' ;; # bright AWS orange — creds are live
|
|
238
|
+
idle) sbg=240 sfg=214 glyph=$' ' ;; # grey clock — lapsed, will refresh on next use
|
|
239
|
+
gone) sbg=160 sfg=231 glyph=$' ' ;; # red cross — no cached token, run brolly
|
|
240
|
+
plain) sbg=238 sfg=250 glyph='' ;; # non-SSO profile — neutral
|
|
241
|
+
esac
|
|
242
|
+
|
|
243
|
+
# powerline pill: amazon-glyph | profile ; \001/\002 wrap non-printing bytes for correct prompt-width math
|
|
244
|
+
local E=$'\033' A=$'\001' B=$'\002' PL=$'' AWS=$''
|
|
245
|
+
printf '%s%s%s%s%s' \
|
|
246
|
+
"${A}${E}[48;5;238;38;5;214;1m${B} ${AWS} " \
|
|
247
|
+
"${A}${E}[38;5;238;48;5;${sbg}m${B}${PL}" \
|
|
248
|
+
"${A}${E}[48;5;${sbg};38;5;${sfg};1m${B} ${glyph}${AWS_PROFILE}${acct:+ · $acct} " \
|
|
249
|
+
"${A}${E}[0;38;5;${sbg}m${B}${PL}" \
|
|
250
|
+
"${A}${E}[0m${B} "
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## How it compares
|
|
255
|
+
|
|
256
|
+
No single existing tool combines what brolly does. Its niche is the *combination*: native to the `sso-session` block
|
|
257
|
+
+ in-place `switch`/`add` + never mutating `$AWS_PROFILE` + a shipped freshness-aware prompt pill +
|
|
258
|
+
pure-Python/pip.
|
|
259
|
+
|
|
260
|
+
- **[aws-sso-util](https://github.com/benkehoe/aws-sso-util)** (Ben Kehoe) is the closest sibling — pure-Python,
|
|
261
|
+
config-native, and non-invasive, in the same spirit as brolly. But it has no prompt integration, no in-place
|
|
262
|
+
single-profile repoint, and predates the `sso-session` block.
|
|
263
|
+
- **[granted](https://granted.dev/)** and **[aws-sso-cli](https://github.com/synfinatic/aws-sso-cli)** are
|
|
264
|
+
excellent tools, but they're Go binaries that install a shell wrapper mutating your shell and generate bulk
|
|
265
|
+
static profiles. brolly is deliberately thinner: no wrapper, no bulk generation, in-place edits only.
|
|
266
|
+
- **[awsume](https://awsu.me/)** targets classic IAM role assumption, not Identity Center.
|
|
267
|
+
|
|
268
|
+
This is positioning, not disparagement — if you want OS-keychain-encrypted tokens today, reach for aws-vault,
|
|
269
|
+
granted, or aws-sso-cli (see caveats below and the roadmap).
|
|
270
|
+
|
|
271
|
+
## Design notes & caveats
|
|
272
|
+
|
|
273
|
+
Two things to own up front:
|
|
274
|
+
|
|
275
|
+
- **`switch` rewrites `~/.aws/config` globally.** A profile lives in one shared config file, so switching the
|
|
276
|
+
account/role of a profile name silently retargets *any other shell* pinned to the **same profile name** on its
|
|
277
|
+
next command. Safe use = **one distinct profile name per concurrent context.** If you keep two shells on the
|
|
278
|
+
same account simultaneously, give them different profile names. This is the one real footgun — stated plainly.
|
|
279
|
+
- **Tokens live in the stock plaintext `~/.aws/sso/cache/`** — the very same cache the `aws` CLI uses. brolly
|
|
280
|
+
adds no encryption of its own; it's a thin layer over the stock cache by design. For OS-keychain encryption
|
|
281
|
+
*today*, use aws-vault / granted / aws-sso-cli. Encrypted storage is the lead roadmap item below.
|
|
282
|
+
|
|
283
|
+
## Roadmap
|
|
284
|
+
|
|
285
|
+
1. **Keychain-backed token storage via a `credential_process` mode.** An opt-in mode where brolly does the
|
|
286
|
+
device auth itself, stores the SSO token in the OS keychain (via the Python
|
|
287
|
+
[`keyring`](https://github.com/jaraco/keyring) library), and registers itself as each profile's
|
|
288
|
+
`credential_process` — so credentials never sit in plaintext on disk and SDK consumers stay fully transparent
|
|
289
|
+
(just `AWS_PROFILE`, no shell wrapper). The prompt pill's freshness check would then read a small non-secret
|
|
290
|
+
expiry sidecar instead of the plaintext cache. On headless Linux with no OS keychain, `keyring` falls back to
|
|
291
|
+
an encrypted-file backend guarded by a passphrase. This mode makes brolly "take over credential resolution,"
|
|
292
|
+
which is exactly the trade the plaintext default avoids — so it stays **opt-in**, keeping brolly a thin stock-
|
|
293
|
+
cache layer by default.
|
|
294
|
+
2. Test suite, CI, and publish to the `fduplex` PyPI org.
|
|
295
|
+
|
|
296
|
+
## License
|
|
297
|
+
|
|
298
|
+
[Apache-2.0](LICENSE) © 2026 Full Duplex Media
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = 'brolly'
|
|
3
|
+
version = '0.1.0'
|
|
4
|
+
description = 'A pure-Python CLI for AWS IAM Identity Center (SSO)'
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = 'Full Duplex Media', email = 'contact@fullduplex.media' }
|
|
7
|
+
]
|
|
8
|
+
readme = 'README.md'
|
|
9
|
+
requires-python = '>=3.14'
|
|
10
|
+
license = { text = 'Apache-2.0' }
|
|
11
|
+
keywords = ['aws', 'sso', 'iam-identity-center', 'cli', 'boto3', 'aws-profile']
|
|
12
|
+
classifiers = [
|
|
13
|
+
'Development Status :: 4 - Beta',
|
|
14
|
+
'Environment :: Console',
|
|
15
|
+
'Intended Audience :: Developers',
|
|
16
|
+
'Intended Audience :: System Administrators',
|
|
17
|
+
'Operating System :: POSIX',
|
|
18
|
+
'Operating System :: MacOS',
|
|
19
|
+
'Programming Language :: Python :: 3',
|
|
20
|
+
'Programming Language :: Python :: 3 :: Only',
|
|
21
|
+
'Programming Language :: Python :: 3.14',
|
|
22
|
+
'Topic :: Utilities',
|
|
23
|
+
'Topic :: System :: Systems Administration :: Authentication/Directory'
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
'boto3'
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
'ruff',
|
|
32
|
+
'ty'
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
brolly = 'brolly.cli:app'
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = 'https://github.com/fduplex/brolly'
|
|
40
|
+
Repository = 'https://github.com/fduplex/brolly'
|
|
41
|
+
Issues = 'https://github.com/fduplex/brolly/issues'
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ['uv_build>=0.11.0,<0.12.0']
|
|
45
|
+
build-backend = 'uv_build'
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 120
|
|
49
|
+
target-version = 'py314'
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ['E', 'F', 'UP', 'B', 'SIM']
|
|
53
|
+
ignore = ['SIM117']
|
|
54
|
+
flake8-quotes = { inline-quotes = 'single', multiline-quotes = 'double' }
|
|
55
|
+
pydocstyle = { convention = 'google' }
|
|
56
|
+
|
|
57
|
+
[tool.ruff.format]
|
|
58
|
+
preview = true
|
|
59
|
+
quote-style = 'single'
|
|
60
|
+
indent-style = 'space'
|
|
61
|
+
docstring-code-format = true
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.1.0'
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""brolly — authenticate, repoint, or create AWS SSO profiles.
|
|
3
|
+
|
|
4
|
+
usage:
|
|
5
|
+
brolly verify/refresh the current profile (same as `brolly refresh`)
|
|
6
|
+
brolly login [-s <session>] force a fresh device-code login for a session
|
|
7
|
+
brolly switch pick a new account/role for $AWS_PROFILE (rewrites the profile in place)
|
|
8
|
+
brolly refresh [<profile>] [-s <session>]
|
|
9
|
+
cheaply verify/refresh a profile's credentials, logging in only if the
|
|
10
|
+
session is dead; defaults to $AWS_PROFILE
|
|
11
|
+
brolly add <profile> [-s <session>]
|
|
12
|
+
create a new profile under an existing sso-session, pick its account/role,
|
|
13
|
+
and leave it authenticated
|
|
14
|
+
|
|
15
|
+
Bare `brolly` no longer force-logs-in — it refreshes the current profile; use `brolly login` for a forced login.
|
|
16
|
+
|
|
17
|
+
-s/--session asserts which sso-session you are operating under (default: the current $AWS_PROFILE's).
|
|
18
|
+
For refresh the target profile must actually belong to the asserted session, or the command fails loudly —
|
|
19
|
+
crossing sessions is always deliberate.
|
|
20
|
+
|
|
21
|
+
$AWS_PROFILE is never modified — it is the fixed handle; `switch` only changes what it resolves to, `add`
|
|
22
|
+
creates a new profile without changing which one the shell uses, and `refresh` targets the aws CLI with
|
|
23
|
+
--profile rather than touching the ambient env.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import os
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import termios
|
|
32
|
+
import tty
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import boto3
|
|
36
|
+
import botocore.session
|
|
37
|
+
from botocore.exceptions import SSOTokenLoadError, TokenRetrievalError, UnauthorizedSSOTokenError
|
|
38
|
+
from botocore.tokens import create_token_resolver
|
|
39
|
+
|
|
40
|
+
type ProfileName = str
|
|
41
|
+
type SessionName = str
|
|
42
|
+
type Region = str
|
|
43
|
+
type AccountId = str
|
|
44
|
+
type RoleName = str
|
|
45
|
+
type AccessToken = str
|
|
46
|
+
type AwsConfig = dict[str, Any] # botocore's full_config / scoped-config nested-dict shape
|
|
47
|
+
type Account = dict[str, str] # a boto3 sso.list_accounts entry: accountId, accountName, emailAddress
|
|
48
|
+
|
|
49
|
+
_ORANGE = '\033[38;5;214m'
|
|
50
|
+
_DIM = '\033[2m'
|
|
51
|
+
_RESET = '\033[0m'
|
|
52
|
+
|
|
53
|
+
# nerd-font glyphs (match the shell prompt's AWS pill)
|
|
54
|
+
_AWS = '' # amazon
|
|
55
|
+
_ACCT = '' # institution / account
|
|
56
|
+
_ROLE = '' # key / role
|
|
57
|
+
_CURSOR = '' # caret-right
|
|
58
|
+
_CURRENT = '' # check-circle
|
|
59
|
+
_CHECK = '' # check
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _require_aws() -> None:
|
|
63
|
+
"""Fail early with install guidance if the AWS CLI v2 is not on PATH — brolly shells out to `aws`."""
|
|
64
|
+
if shutil.which('aws') is None:
|
|
65
|
+
raise SystemExit(
|
|
66
|
+
'brolly needs the AWS CLI v2 (`aws`) on your PATH. Install it: '
|
|
67
|
+
'https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html'
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _aws(*args: str, capture: bool = True) -> subprocess.CompletedProcess[str]:
|
|
72
|
+
"""Run the aws CLI. capture=False lets an interactive command own the terminal (device-code login)."""
|
|
73
|
+
result = subprocess.run(['aws', *args], text=True, capture_output=capture)
|
|
74
|
+
if result.returncode != 0:
|
|
75
|
+
detail = (result.stderr or '').strip() if capture else ''
|
|
76
|
+
raise SystemExit(f'aws {" ".join(args)} failed{": " + detail if detail else ""}')
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _profile_sso(profile: ProfileName) -> tuple[SessionName, Region, AccountId | None, RoleName | None]:
|
|
81
|
+
"""Return (session, region, account_id, role) for the profile; exit if it is not an SSO profile."""
|
|
82
|
+
session = botocore.session.Session(profile=profile)
|
|
83
|
+
cfg = session.get_scoped_config()
|
|
84
|
+
session_name: SessionName | None = cfg.get('sso_session')
|
|
85
|
+
if not session_name:
|
|
86
|
+
raise SystemExit(f"profile '{profile}' has no sso_session")
|
|
87
|
+
region: Region = session.full_config['sso_sessions'][session_name]['sso_region']
|
|
88
|
+
return session_name, region, cfg.get('sso_account_id'), cfg.get('sso_role_name')
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolve_token(profile: ProfileName) -> AccessToken | None:
|
|
92
|
+
"""A valid SSO access token — refreshed if the hourly one lapsed; None if the 7-day session is dead."""
|
|
93
|
+
resolver = create_token_resolver(botocore.session.Session(profile=profile))
|
|
94
|
+
try:
|
|
95
|
+
token = resolver.load_token()
|
|
96
|
+
return token.get_frozen_token().token if token is not None else None
|
|
97
|
+
except TokenRetrievalError, SSOTokenLoadError, UnauthorizedSSOTokenError:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ensure_token(profile: ProfileName, session_name: SessionName) -> AccessToken:
|
|
102
|
+
token = _resolve_token(profile)
|
|
103
|
+
if token is not None:
|
|
104
|
+
return token
|
|
105
|
+
# session fully expired (>7d): the refresh token is dead, so log in interactively, then resolve again
|
|
106
|
+
print(f"SSO session '{session_name}' expired — logging in…", file=sys.stderr)
|
|
107
|
+
_aws('sso', 'login', '--sso-session', session_name, '--no-browser', '--use-device-code', capture=False)
|
|
108
|
+
token = _resolve_token(profile)
|
|
109
|
+
if token is None:
|
|
110
|
+
raise SystemExit('could not obtain a valid SSO token after login')
|
|
111
|
+
return token
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _read_key() -> str:
|
|
115
|
+
ch = sys.stdin.read(1)
|
|
116
|
+
if ch == '\x1b':
|
|
117
|
+
return {'[A': 'up', '[B': 'down'}.get(sys.stdin.read(2), 'esc')
|
|
118
|
+
if ch in ('\r', '\n'):
|
|
119
|
+
return 'enter'
|
|
120
|
+
if ch == '\x03':
|
|
121
|
+
return 'ctrl-c'
|
|
122
|
+
return ch
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _render(rows: list[str], idx: int, current: int) -> None:
|
|
126
|
+
for i, row in enumerate(rows):
|
|
127
|
+
cursor = f'{_ORANGE}{_CURSOR}{_RESET}' if i == idx else ' '
|
|
128
|
+
dot = f'{_ORANGE}{_CURRENT}{_RESET}' if i == current else ' '
|
|
129
|
+
body = f'\033[7m {row} {_RESET}' if i == idx else f' {row} '
|
|
130
|
+
sys.stdout.write(f'\r\033[K {cursor} {dot} {body}\n')
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _menu(header: str, columns: list[str], rows: list[list[str]], default: int, current: int) -> int:
|
|
134
|
+
"""Arrow-key selector over tabular rows; returns the chosen index. Falls back to numeric input off a TTY."""
|
|
135
|
+
widths = [max(len(columns[c]), *(len(r[c]) for r in rows)) for c in range(len(columns))]
|
|
136
|
+
lines = [' '.join(cell.ljust(widths[c]) for c, cell in enumerate(row)) for row in rows]
|
|
137
|
+
head = ' '.join(col.ljust(widths[c]) for c, col in enumerate(columns))
|
|
138
|
+
|
|
139
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
140
|
+
print(f'\n{header}\n {head}')
|
|
141
|
+
for i, line in enumerate(lines):
|
|
142
|
+
print(f' {i + 1:>2}) {line}{" ← current" if i == current else ""}')
|
|
143
|
+
while True:
|
|
144
|
+
try:
|
|
145
|
+
raw = input(f'select [1-{len(rows)}] (default {default + 1})> ').strip()
|
|
146
|
+
except EOFError:
|
|
147
|
+
raise SystemExit(130) from None
|
|
148
|
+
if not raw:
|
|
149
|
+
return default
|
|
150
|
+
if raw.isdigit() and 1 <= int(raw) <= len(rows):
|
|
151
|
+
return int(raw) - 1
|
|
152
|
+
|
|
153
|
+
idx = default
|
|
154
|
+
print(f'\n{header} {_DIM}↑/↓ move · enter select · q quit{_RESET}')
|
|
155
|
+
print(f' {_DIM}{head}{_RESET}')
|
|
156
|
+
_render(lines, idx, current)
|
|
157
|
+
fd = sys.stdin.fileno()
|
|
158
|
+
saved = termios.tcgetattr(fd)
|
|
159
|
+
sys.stdout.write('\033[?25l')
|
|
160
|
+
sys.stdout.flush()
|
|
161
|
+
try:
|
|
162
|
+
tty.setcbreak(fd)
|
|
163
|
+
while True:
|
|
164
|
+
key = _read_key()
|
|
165
|
+
if key in ('up', 'k'):
|
|
166
|
+
idx = (idx - 1) % len(rows)
|
|
167
|
+
elif key in ('down', 'j'):
|
|
168
|
+
idx = (idx + 1) % len(rows)
|
|
169
|
+
elif key == 'enter':
|
|
170
|
+
return idx
|
|
171
|
+
elif key in ('q', 'esc', 'ctrl-c'):
|
|
172
|
+
raise SystemExit(130)
|
|
173
|
+
else:
|
|
174
|
+
continue
|
|
175
|
+
sys.stdout.write(f'\033[{len(rows)}A')
|
|
176
|
+
_render(lines, idx, current)
|
|
177
|
+
sys.stdout.flush()
|
|
178
|
+
finally:
|
|
179
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
180
|
+
sys.stdout.write('\033[?25h')
|
|
181
|
+
sys.stdout.flush()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_login(session: SessionName) -> None:
|
|
185
|
+
_aws('sso', 'login', '--sso-session', session, '--no-browser', '--use-device-code', capture=False)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _pick_account_role(
|
|
189
|
+
sso_region: Region, token: AccessToken, cur_account: AccountId | None = None, cur_role: RoleName | None = None
|
|
190
|
+
) -> tuple[Account, RoleName]:
|
|
191
|
+
"""Interactively choose an account and role for the session; pre-selects cur_account/cur_role when given."""
|
|
192
|
+
sso = boto3.client('sso', region_name=sso_region)
|
|
193
|
+
|
|
194
|
+
accounts: list[Account] = []
|
|
195
|
+
for page in sso.get_paginator('list_accounts').paginate(accessToken=token):
|
|
196
|
+
accounts.extend(page['accountList'])
|
|
197
|
+
if not accounts:
|
|
198
|
+
raise SystemExit('no accounts available for this session')
|
|
199
|
+
accounts.sort(key=lambda a: a['accountName'])
|
|
200
|
+
|
|
201
|
+
acct_current = next((i for i, a in enumerate(accounts) if a['accountId'] == cur_account), -1)
|
|
202
|
+
rows = [[a['accountId'], a['accountName']] for a in accounts]
|
|
203
|
+
header = f'{_AWS} {_ACCT} select account'
|
|
204
|
+
account = accounts[_menu(header, ['ACCOUNT', 'NAME'], rows, max(acct_current, 0), acct_current)]
|
|
205
|
+
account_id: AccountId = account['accountId']
|
|
206
|
+
|
|
207
|
+
roles: list[RoleName] = []
|
|
208
|
+
for page in sso.get_paginator('list_account_roles').paginate(accessToken=token, accountId=account_id):
|
|
209
|
+
roles.extend(r['roleName'] for r in page['roleList'])
|
|
210
|
+
if not roles:
|
|
211
|
+
raise SystemExit(f'no roles available in {account_id}')
|
|
212
|
+
if len(roles) == 1:
|
|
213
|
+
role = roles[0]
|
|
214
|
+
print(f'{_ROLE} only role: {role}')
|
|
215
|
+
else:
|
|
216
|
+
role_current = next((i for i, r in enumerate(roles) if r == cur_role and account_id == cur_account), -1)
|
|
217
|
+
header = f'{_AWS} {_ROLE} select role'
|
|
218
|
+
role = roles[_menu(header, ['ROLE'], [[r] for r in roles], max(role_current, 0), role_current)]
|
|
219
|
+
return account, role
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _write_account_role(profile: ProfileName, account: Account, role: RoleName) -> None:
|
|
223
|
+
_aws('configure', 'set', 'sso_account_id', account['accountId'], '--profile', profile)
|
|
224
|
+
_aws('configure', 'set', 'sso_role_name', role, '--profile', profile)
|
|
225
|
+
_aws('configure', 'set', 'sso_account_name', account['accountName'], '--profile', profile)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_switch(profile: ProfileName) -> None:
|
|
229
|
+
session_name, sso_region, cur_account, cur_role = _profile_sso(profile)
|
|
230
|
+
token = _ensure_token(profile, session_name)
|
|
231
|
+
account, role = _pick_account_role(sso_region, token, cur_account, cur_role)
|
|
232
|
+
account_id = account['accountId']
|
|
233
|
+
_write_account_role(profile, account, role)
|
|
234
|
+
print(f'\n{_ORANGE}{_CHECK}{_RESET} {profile} → {account_id} ({account["accountName"]}) / {role}')
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cmd_add(session: SessionName, new_profile: ProfileName, full_config: AwsConfig) -> None:
|
|
238
|
+
sso_sessions = full_config['sso_sessions']
|
|
239
|
+
profiles = full_config['profiles']
|
|
240
|
+
|
|
241
|
+
if session not in sso_sessions:
|
|
242
|
+
raise SystemExit(f"unknown sso-session '{session}' — available: {', '.join(sorted(sso_sessions))}")
|
|
243
|
+
if new_profile in profiles:
|
|
244
|
+
raise SystemExit(f"profile '{new_profile}' already exists — use `brolly switch` to repoint it")
|
|
245
|
+
|
|
246
|
+
sso_region: Region = sso_sessions[session]['sso_region']
|
|
247
|
+
sibling = next((p for p in profiles.values() if p.get('sso_session') == session), {})
|
|
248
|
+
_aws('configure', 'set', 'sso_session', session, '--profile', new_profile)
|
|
249
|
+
_aws('configure', 'set', 'region', sibling.get('region', sso_region), '--profile', new_profile)
|
|
250
|
+
_aws('configure', 'set', 'output', sibling.get('output', 'json'), '--profile', new_profile)
|
|
251
|
+
|
|
252
|
+
token = _ensure_token(new_profile, session)
|
|
253
|
+
account, role = _pick_account_role(sso_region, token)
|
|
254
|
+
account_id = account['accountId']
|
|
255
|
+
_write_account_role(new_profile, account, role)
|
|
256
|
+
print(f'\n{_ORANGE}{_CHECK}{_RESET} added {new_profile} → {account_id} ({account["accountName"]}) / {role}')
|
|
257
|
+
print(f'{_DIM} use it: export AWS_PROFILE={new_profile}{_RESET}')
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _backfill_account_name(profile: ProfileName, sso_region: Region, account_id: AccountId | None) -> None:
|
|
261
|
+
if not account_id:
|
|
262
|
+
return
|
|
263
|
+
if botocore.session.Session(profile=profile).get_scoped_config().get('sso_account_name'):
|
|
264
|
+
return
|
|
265
|
+
token = _resolve_token(profile)
|
|
266
|
+
if token is None:
|
|
267
|
+
return
|
|
268
|
+
sso = boto3.client('sso', region_name=sso_region)
|
|
269
|
+
for page in sso.get_paginator('list_accounts').paginate(accessToken=token):
|
|
270
|
+
for a in page['accountList']:
|
|
271
|
+
if a['accountId'] == account_id:
|
|
272
|
+
_aws('configure', 'set', 'sso_account_name', a['accountName'], '--profile', profile)
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def cmd_refresh(target_profile: ProfileName, session: SessionName, full_config: AwsConfig) -> None:
|
|
277
|
+
profiles = full_config['profiles']
|
|
278
|
+
if target_profile not in profiles:
|
|
279
|
+
raise SystemExit(f"unknown profile '{target_profile}' — available: {', '.join(sorted(profiles))}")
|
|
280
|
+
actual: SessionName | None = profiles[target_profile].get('sso_session')
|
|
281
|
+
if not actual:
|
|
282
|
+
raise SystemExit(f"profile '{target_profile}' is not an SSO profile (no sso_session)")
|
|
283
|
+
if actual != session:
|
|
284
|
+
raise SystemExit(
|
|
285
|
+
f"profile '{target_profile}' is under session '{actual}', not '{session}' — use -s {actual} to target it"
|
|
286
|
+
)
|
|
287
|
+
if actual not in full_config['sso_sessions']:
|
|
288
|
+
raise SystemExit(f"sso-session '{actual}' referenced by '{target_profile}' is not defined")
|
|
289
|
+
sso_region: Region = full_config['sso_sessions'][actual]['sso_region']
|
|
290
|
+
account_id: AccountId | None = profiles[target_profile].get('sso_account_id')
|
|
291
|
+
check = subprocess.run(
|
|
292
|
+
['aws', 'sts', 'get-caller-identity', '--profile', target_profile, '--query', 'Arn', '--output', 'text'],
|
|
293
|
+
text=True,
|
|
294
|
+
capture_output=True,
|
|
295
|
+
)
|
|
296
|
+
if check.returncode != 0:
|
|
297
|
+
print(f'{target_profile}: credentials unavailable — logging in…', file=sys.stderr)
|
|
298
|
+
_aws('sso', 'login', '--sso-session', actual, '--no-browser', '--use-device-code', capture=False)
|
|
299
|
+
arn = _aws(
|
|
300
|
+
'sts', 'get-caller-identity', '--profile', target_profile, '--query', 'Arn', '--output', 'text'
|
|
301
|
+
).stdout.strip()
|
|
302
|
+
else:
|
|
303
|
+
arn = check.stdout.strip()
|
|
304
|
+
_backfill_account_name(target_profile, sso_region, account_id)
|
|
305
|
+
print(f'{_ORANGE}{_CHECK}{_RESET} {target_profile} live → {arn}')
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
309
|
+
parser = argparse.ArgumentParser(prog='brolly', description='authenticate, repoint, or create AWS SSO profiles')
|
|
310
|
+
sub = parser.add_subparsers(dest='cmd')
|
|
311
|
+
|
|
312
|
+
login = sub.add_parser('login', help='force a fresh device-code login for a session')
|
|
313
|
+
login.add_argument('-s', '--session', help="sso-session to log into (default: current profile's)")
|
|
314
|
+
|
|
315
|
+
sub.add_parser('switch', help='pick a new account/role for $AWS_PROFILE')
|
|
316
|
+
|
|
317
|
+
refresh = sub.add_parser('refresh', help="verify/refresh a profile's credentials")
|
|
318
|
+
refresh.add_argument('profile', nargs='?', help='target profile (default: $AWS_PROFILE)')
|
|
319
|
+
refresh.add_argument('-s', '--session', help="assert the sso-session (default: current profile's)")
|
|
320
|
+
|
|
321
|
+
add = sub.add_parser('add', help='create a new profile under an sso-session')
|
|
322
|
+
add.add_argument('profile', help='new profile name')
|
|
323
|
+
add.add_argument('-s', '--session', help="session to create it under (default: current profile's)")
|
|
324
|
+
|
|
325
|
+
return parser
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _session_in_context(current: ProfileName, full_config: AwsConfig, override: SessionName | None) -> SessionName:
|
|
329
|
+
"""The sso-session to operate under: an explicit -s override, else the current profile's; exit if neither."""
|
|
330
|
+
session = override or full_config['profiles'].get(current, {}).get('sso_session')
|
|
331
|
+
if not session:
|
|
332
|
+
raise SystemExit(
|
|
333
|
+
f"no sso-session in context — current profile '{current}' has no sso_session; pass -s <session>"
|
|
334
|
+
)
|
|
335
|
+
return session
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def main(argv: list[str]) -> None:
|
|
339
|
+
_require_aws()
|
|
340
|
+
current: ProfileName = os.environ.get('AWS_PROFILE', 'default')
|
|
341
|
+
parser = _build_parser()
|
|
342
|
+
args = parser.parse_args(argv or ['refresh'])
|
|
343
|
+
full_config: AwsConfig = botocore.session.Session().full_config
|
|
344
|
+
if args.cmd == 'login':
|
|
345
|
+
session = _session_in_context(current, full_config, args.session)
|
|
346
|
+
if session not in full_config['sso_sessions']:
|
|
347
|
+
raise SystemExit(
|
|
348
|
+
f"unknown sso-session '{session}' — available: {', '.join(sorted(full_config['sso_sessions']))}"
|
|
349
|
+
)
|
|
350
|
+
cmd_login(session)
|
|
351
|
+
elif args.cmd == 'switch':
|
|
352
|
+
cmd_switch(current)
|
|
353
|
+
elif args.cmd == 'refresh':
|
|
354
|
+
target = args.profile or current
|
|
355
|
+
cmd_refresh(target, _session_in_context(current, full_config, args.session), full_config)
|
|
356
|
+
elif args.cmd == 'add':
|
|
357
|
+
cmd_add(_session_in_context(current, full_config, args.session), args.profile, full_config)
|
|
358
|
+
else:
|
|
359
|
+
parser.print_usage(sys.stderr)
|
|
360
|
+
raise SystemExit(2)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def app() -> None:
|
|
364
|
+
try:
|
|
365
|
+
main(sys.argv[1:])
|
|
366
|
+
except KeyboardInterrupt:
|
|
367
|
+
raise SystemExit(130) from None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == '__main__':
|
|
371
|
+
app()
|