Skip to content

Commit 8eb859b

Browse files
committed
feat: add silent token refresh using SSO OIDC API
- Use refresh_token grant type to silently refresh access tokens - Only fall back to browser when refresh token is expired/invalid - Track browser login times to estimate when re-auth will be needed - Add AWS_SSO_SESSION_DURATION config for accurate estimates - Show "browser re-auth in Xh" in status output
1 parent cce2d80 commit 8eb859b

File tree

2 files changed

+220
-24
lines changed

2 files changed

+220
-24
lines changed

README.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ When using AWS SSO (Identity Center), your session tokens expire after a few hou
88

99
## The Solution
1010

11-
`aws-sso-refresh` runs as a background daemon and proactively refreshes your SSO sessions before they expire. It opens a browser window for re-authentication (which auto-approves if you're already logged in), so your tokens stay fresh.
11+
`aws-sso-refresh` runs as a background daemon and proactively refreshes your SSO sessions before they expire. It uses the AWS SSO OIDC API to **silently refresh tokens** without opening a browser. Only when the underlying session has truly expired does it fall back to browser-based re-authentication.
1212

1313
## Installation
1414

@@ -57,8 +57,8 @@ aws-sso-refresh help
5757

5858
1. **Parses** your `~/.aws/config` to find all `[sso-session]` blocks
5959
2. **Checks** the token cache at `~/.aws/sso/cache/` for expiration times
60-
3. **Refreshes** sessions within 30 minutes of expiring via `aws sso login --sso-session <name>`
61-
4. **Opens a browser** for re-authentication (auto-approves if already logged in)
60+
3. **Silently refreshes** sessions using the SSO OIDC API with the stored refresh token (no browser needed!)
61+
4. **Falls back to browser** only when the refresh token itself has expired (rare - typically after the Identity Center session duration ends)
6262

6363
### Example Status Output
6464

@@ -91,6 +91,14 @@ By default, the daemon checks sessions every 10 minutes. Customize this with:
9191
export AWS_SSO_REFRESH_INTERVAL=5 # Check every 5 minutes (min: 1, max: 60)
9292
```
9393

94+
### Session Duration
95+
96+
For accurate "browser re-auth" estimates in status output, set this to match your Identity Center session duration:
97+
98+
```bash
99+
export AWS_SSO_SESSION_DURATION=8 # Default: 8 hours (check with your AWS admin)
100+
```
101+
94102
**Note:** After changing these values, run `aws-sso-refresh uninstall` and `aws-sso-refresh install` to update the daemon configuration.
95103

96104
Add these exports to your `~/.zshrc` or `~/.bashrc` to persist them.
@@ -145,9 +153,17 @@ brew install bash
145153
2. Check the logs: `aws-sso-refresh logs`
146154
3. Run manually to test: `aws-sso-refresh`
147155

148-
### Browser doesn't auto-approve
156+
### Browser keeps opening
157+
158+
If the browser opens frequently for re-authentication, it means the underlying Identity Center session has expired and the refresh token can no longer silently refresh. This typically happens when:
159+
160+
- The session duration in AWS Identity Center is set to a short period (e.g., 1 hour)
161+
- You've been away from your computer for longer than the session duration
162+
- The Identity Center administrator has revoked your session
163+
164+
After re-authenticating in the browser once, subsequent refreshes should be silent again until the session duration expires.
149165

150-
Your Identity Center session may have expired. You'll need to manually approve in the browser once, then subsequent refreshes should be automatic.
166+
**Note:** The session duration is configured by your AWS administrator in Identity Center settings (typically 8-12 hours by default, up to 7 days).
151167

152168
## License
153169

bin/aws-sso-refresh

Lines changed: 199 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,17 @@
1919
set -eo pipefail
2020

2121
# Version
22-
VERSION="1.0.3"
22+
VERSION="1.1.0"
2323

2424
# Configuration
2525
REFRESH_THRESHOLD_MINUTES="${AWS_SSO_REFRESH_THRESHOLD:-30}"
2626
REFRESH_INTERVAL_MINUTES="${AWS_SSO_REFRESH_INTERVAL:-10}"
27+
SESSION_DURATION_HOURS="${AWS_SSO_SESSION_DURATION:-8}" # Identity Center session duration (default: 8 hours)
2728
SSO_CACHE_DIR="$HOME/.aws/sso/cache"
2829
AWS_CONFIG="$HOME/.aws/config"
2930
LOG_DIR="$HOME/.local/share/aws-sso-refresh"
3031
LOG_FILE="$LOG_DIR/refresh.log"
32+
SESSIONS_FILE="$LOG_DIR/sessions.json" # Tracks browser login times
3133
PLIST_NAME="com.aws.sso-refresh"
3234
PLIST_PATH="$HOME/Library/LaunchAgents/${PLIST_NAME}.plist"
3335

@@ -188,6 +190,159 @@ get_session_info() {
188190
return 1
189191
}
190192

193+
# Calculate future epoch timestamp (macOS compatible)
194+
# Args: seconds_from_now
195+
epoch_plus_seconds() {
196+
local seconds="$1"
197+
local current_epoch
198+
current_epoch=$(date "+%s")
199+
echo $((current_epoch + seconds))
200+
}
201+
202+
# Convert epoch to ISO 8601 timestamp
203+
epoch_to_iso() {
204+
local epoch="$1"
205+
# Try GNU date first, then BSD date
206+
if date --version &>/dev/null; then
207+
date -u -d "@$epoch" "+%Y-%m-%dT%H:%M:%SZ" 2>/dev/null
208+
else
209+
date -u -r "$epoch" "+%Y-%m-%dT%H:%M:%SZ" 2>/dev/null
210+
fi
211+
}
212+
213+
# Record when a browser login happened for a session
214+
record_browser_login() {
215+
local session_name="$1"
216+
local login_time
217+
login_time=$(date -u "+%Y-%m-%dT%H:%M:%SZ")
218+
219+
# Create sessions file if it doesn't exist
220+
if [[ ! -f "$SESSIONS_FILE" ]]; then
221+
echo "{}" > "$SESSIONS_FILE"
222+
fi
223+
224+
# Update the session's last browser login time
225+
local tmp_file="${SESSIONS_FILE}.tmp.$$"
226+
if jq --arg session "$session_name" \
227+
--arg time "$login_time" \
228+
'.[$session] = {lastBrowserLogin: $time}' \
229+
"$SESSIONS_FILE" > "$tmp_file" 2>/dev/null && mv "$tmp_file" "$SESSIONS_FILE"; then
230+
return 0
231+
else
232+
rm -f "$tmp_file"
233+
return 1
234+
fi
235+
}
236+
237+
# Get the last browser login time for a session
238+
get_last_browser_login() {
239+
local session_name="$1"
240+
[[ -f "$SESSIONS_FILE" ]] || return 1
241+
jq -r --arg session "$session_name" '.[$session].lastBrowserLogin // empty' "$SESSIONS_FILE" 2>/dev/null
242+
}
243+
244+
# Calculate minutes until browser re-auth will be needed
245+
# Returns: minutes until session expires, or -1 if unknown
246+
get_session_expiry_minutes() {
247+
local session_name="$1"
248+
local last_login
249+
250+
last_login=$(get_last_browser_login "$session_name")
251+
[[ -n "$last_login" ]] || return 1
252+
253+
local login_epoch current_epoch session_duration_seconds session_expiry_epoch
254+
login_epoch=$(iso_to_epoch "$last_login")
255+
current_epoch=$(date "+%s")
256+
session_duration_seconds=$((SESSION_DURATION_HOURS * 3600))
257+
session_expiry_epoch=$((login_epoch + session_duration_seconds))
258+
259+
local seconds_until_expiry=$((session_expiry_epoch - current_epoch))
260+
echo $((seconds_until_expiry / 60))
261+
}
262+
263+
# Attempt silent token refresh using the SSO OIDC refresh_token
264+
# Returns 0 on success, 1 on failure (requires browser login)
265+
silent_refresh_session() {
266+
local cache_file="$1"
267+
local session_name="$2"
268+
269+
# Extract required fields from cache file
270+
local client_id client_secret refresh_token region
271+
client_id=$(jq -r '.clientId // empty' "$cache_file" 2>/dev/null)
272+
client_secret=$(jq -r '.clientSecret // empty' "$cache_file" 2>/dev/null)
273+
refresh_token=$(jq -r '.refreshToken // empty' "$cache_file" 2>/dev/null)
274+
region=$(jq -r '.region // "us-east-1"' "$cache_file" 2>/dev/null)
275+
276+
# Verify we have all required fields
277+
if [[ -z "$client_id" || -z "$client_secret" || -z "$refresh_token" ]]; then
278+
log "Silent refresh unavailable for '$session_name': missing required tokens"
279+
return 1
280+
fi
281+
282+
# Call AWS SSO OIDC API to refresh the token
283+
local response
284+
if response=$(aws sso-oidc create-token \
285+
--client-id "$client_id" \
286+
--client-secret "$client_secret" \
287+
--grant-type "refresh_token" \
288+
--refresh-token "$refresh_token" \
289+
--region "$region" \
290+
--output json 2>&1); then
291+
292+
# Parse the new tokens from response
293+
local new_access_token new_refresh_token new_expires_in
294+
new_access_token=$(echo "$response" | jq -r '.accessToken // empty')
295+
new_refresh_token=$(echo "$response" | jq -r '.refreshToken // empty')
296+
new_expires_in=$(echo "$response" | jq -r '.expiresIn // empty')
297+
298+
# Validate response
299+
if [[ -z "$new_access_token" || -z "$new_expires_in" ]]; then
300+
log "Silent refresh failed for '$session_name': invalid API response"
301+
return 1
302+
fi
303+
304+
# Check if the new token has meaningful validity (at least 5 minutes)
305+
# If it's too short, the underlying session is near expiry and needs browser re-auth
306+
local min_valid_seconds=300 # 5 minutes
307+
if [[ "$new_expires_in" -lt "$min_valid_seconds" ]]; then
308+
log "Silent refresh for '$session_name' returned short-lived token (${new_expires_in}s), requires browser login"
309+
return 1
310+
fi
311+
312+
# Calculate new expiration timestamp
313+
local new_expires_epoch new_expires_at
314+
new_expires_epoch=$(epoch_plus_seconds "$new_expires_in")
315+
new_expires_at=$(epoch_to_iso "$new_expires_epoch")
316+
317+
# Use existing refresh token if new one wasn't provided
318+
if [[ -z "$new_refresh_token" ]]; then
319+
new_refresh_token="$refresh_token"
320+
fi
321+
322+
# Update the cache file with new tokens
323+
local tmp_file="${cache_file}.tmp.$$"
324+
if jq --arg access "$new_access_token" \
325+
--arg expires "$new_expires_at" \
326+
--arg refresh "$new_refresh_token" \
327+
'.accessToken = $access | .expiresAt = $expires | .refreshToken = $refresh' \
328+
"$cache_file" > "$tmp_file" 2>/dev/null && mv "$tmp_file" "$cache_file"; then
329+
return 0
330+
else
331+
rm -f "$tmp_file"
332+
log "Silent refresh failed for '$session_name': could not update cache file"
333+
return 1
334+
fi
335+
else
336+
# Log the error for debugging (but don't expose sensitive details)
337+
if echo "$response" | grep -qi "expired\|invalid"; then
338+
log "Silent refresh failed for '$session_name': refresh token expired or invalid"
339+
else
340+
log "Silent refresh failed for '$session_name': API call failed"
341+
fi
342+
return 1
343+
fi
344+
}
345+
191346
# Check and refresh a single session
192347
# Args: cache_file session_name [force]
193348
check_and_refresh_session() {
@@ -209,28 +364,37 @@ check_and_refresh_session() {
209364
seconds_until_expiry=$((expires_epoch - current_epoch))
210365
minutes_until_expiry=$((seconds_until_expiry / 60))
211366

212-
# Check if we need to refresh
213-
if [[ "$force" == "true" ]]; then
214-
log "Session '$session_name' force refresh requested (${minutes_until_expiry}m remaining). Refreshing..."
215-
# Perform the refresh
367+
# Helper to perform refresh (silent first, then browser fallback)
368+
do_refresh() {
369+
# Try silent refresh first (no browser)
370+
if silent_refresh_session "$cache_file" "$session_name"; then
371+
log "Successfully refreshed session '$session_name' (silent)"
372+
return 0
373+
fi
374+
375+
# Fall back to browser-based login
376+
log "Falling back to browser login for '$session_name'..."
216377
if aws sso login --sso-session "$session_name" 2>&1 | tee -a "$LOG_FILE"; then
217-
log "Successfully refreshed session '$session_name'"
378+
log "Successfully refreshed session '$session_name' (browser)"
379+
record_browser_login "$session_name" # Track for session expiry estimates
380+
return 0
218381
else
219382
log "ERROR: Failed to refresh session '$session_name'"
383+
return 1
220384
fi
385+
}
386+
387+
# Check if we need to refresh
388+
if [[ "$force" == "true" ]]; then
389+
log "Session '$session_name' force refresh requested (${minutes_until_expiry}m remaining). Refreshing..."
390+
do_refresh
221391
elif [[ $minutes_until_expiry -le $REFRESH_THRESHOLD_MINUTES ]]; then
222392
if [[ $minutes_until_expiry -le 0 ]]; then
223393
log "Session '$session_name' has EXPIRED. Refreshing..."
224394
else
225395
log "Session '$session_name' expires in ${minutes_until_expiry}m (threshold: ${REFRESH_THRESHOLD_MINUTES}m). Refreshing..."
226396
fi
227-
228-
# Perform the refresh
229-
if aws sso login --sso-session "$session_name" 2>&1 | tee -a "$LOG_FILE"; then
230-
log "Successfully refreshed session '$session_name'"
231-
else
232-
log "ERROR: Failed to refresh session '$session_name'"
233-
fi
397+
do_refresh
234398
else
235399
log "Session '$session_name' OK - expires in ${minutes_until_expiry}m"
236400
fi
@@ -318,17 +482,29 @@ cmd_status() {
318482
[[ -v seen_sessions["$session_name"] ]] && continue
319483
seen_sessions["$session_name"]=1
320484

321-
local minutes
485+
local minutes session_minutes session_info_str
322486
if minutes=$(get_session_info "$session_name"); then
323487
local time_str
324488
time_str=$(format_time "$minutes")
325489

490+
# Get session expiry estimate (when browser re-auth will be needed)
491+
session_info_str=""
492+
if session_minutes=$(get_session_expiry_minutes "$session_name"); then
493+
if [[ $session_minutes -gt 0 ]]; then
494+
local session_time_str
495+
session_time_str=$(format_time "$session_minutes")
496+
session_info_str=" ${BLUE}(browser re-auth in ${session_time_str})${NC}"
497+
else
498+
session_info_str=" ${YELLOW}(browser re-auth soon)${NC}"
499+
fi
500+
fi
501+
326502
if [[ $minutes -le 0 ]]; then
327-
echo -e " ${RED}${NC} $session_name ${RED}$time_str${NC}"
503+
echo -e " ${RED}${NC} $session_name ${RED}$time_str${NC}${session_info_str}"
328504
elif [[ $minutes -le $REFRESH_THRESHOLD_MINUTES ]]; then
329-
echo -e " ${YELLOW}!${NC} $session_name ${YELLOW}$time_str remaining${NC} (will refresh soon)"
505+
echo -e " ${YELLOW}!${NC} $session_name ${YELLOW}$time_str remaining${NC} (will refresh soon)${session_info_str}"
330506
else
331-
echo -e " ${GREEN}${NC} $session_name ${GREEN}$time_str remaining${NC}"
507+
echo -e " ${GREEN}${NC} $session_name ${GREEN}$time_str remaining${NC}${session_info_str}"
332508
fi
333509
else
334510
echo -e " ${RED}${NC} $session_name ${RED}no active session${NC}"
@@ -508,9 +684,13 @@ cmd_help() {
508684
echo " Set AWS_SSO_REFRESH_INTERVAL to change how often to check"
509685
echo " (default: 10 minutes, min: 1, max: 60)"
510686
echo ""
687+
echo " Set AWS_SSO_SESSION_DURATION to match your Identity Center session"
688+
echo " duration for accurate browser re-auth estimates (default: 8 hours)"
689+
echo ""
511690
echo " Examples:"
512-
echo " export AWS_SSO_REFRESH_THRESHOLD=5 # Refresh 5m before expiry"
513-
echo " export AWS_SSO_REFRESH_INTERVAL=5 # Check every 5 minutes"
691+
echo " export AWS_SSO_REFRESH_THRESHOLD=5 # Refresh 5m before expiry"
692+
echo " export AWS_SSO_REFRESH_INTERVAL=5 # Check every 5 minutes"
693+
echo " export AWS_SSO_SESSION_DURATION=12 # 12-hour IdC session"
514694
echo ""
515695
echo -e "${BOLD}EXAMPLES:${NC}"
516696
echo " aws-sso-refresh # Show session status"

0 commit comments

Comments
 (0)