From 72ad4a7cb4a6f5af6da8153921f2816d43464eab Mon Sep 17 00:00:00 2001 From: Martin Wimpress Date: Wed, 18 Jun 2025 04:15:29 +0100 Subject: [PATCH] refactor(token): Read TTLs directly from cache files This commit refactors the token time-to-live (TTL) checking logic for improved performance, clarity, and robustness. - The monolithic TTL checking logic has been split into two distinct functions: `get_token_ttl` and `get_refresh_ttl`, each with a single responsibility. - Both functions now read expiration timestamps directly from the `oidc-token` and `refresh-token` files in the `chainctl` cache. This avoids executing the `chainctl` binary, which is significantly faster and prevents the side-effect of triggering an interactive login if a token has expired. - A new helper function, `get_audience_cache_dir`, has been introduced to centralize the logic for constructing cache paths, reducing code duplication. As a result of these changes, the complex and brittle date-parsing functions (`tounix`, `detect_date_type`, `try_date_parse`) are no longer needed and have been removed, simplifying the script considerably. --- .../_mixins/features/development/cg-tokens.sh | 231 ++++++------------ 1 file changed, 77 insertions(+), 154 deletions(-) diff --git a/home-manager/_mixins/features/development/cg-tokens.sh b/home-manager/_mixins/features/development/cg-tokens.sh index b287c789..a4431120 100755 --- a/home-manager/_mixins/features/development/cg-tokens.sh +++ b/home-manager/_mixins/features/development/cg-tokens.sh @@ -20,12 +20,6 @@ AUDIENCES=( "apk.cgr.dev" "cgr.dev" ) -# Global variables to store TTLs, populated by get_current_ttls -CURRENT_TOKEN_TTL_SEC=0 -CURRENT_REFRESH_TTL_SEC=0 -# Global variable to track date command type and label, populated by detect_date_type -DATE_TYPE="" -DATE_LABEL="" # Default headless mode HEADLESS=yes # Default operation mode @@ -52,142 +46,54 @@ OPTIONS: EOF } -# Detect date command type and set global DATE_TYPE variable -detect_date_type() { - if [ -z "$DATE_TYPE" ]; then - if date --help 2>&1 | grep -q "BusyBox v"; then - DATE_LABEL="BusyBox" - DATE_TYPE="busybox" - elif date --help 2>&1 | grep -q "coreutils"; then - DATE_LABEL="GNU Core Utilities" - DATE_TYPE="coreutils" - elif [[ "$(uname)" == "Darwin" ]]; then - DATE_LABEL="BSDCoreUtils (macOS)" - DATE_TYPE="bsd" - else - DATE_LABEL="Unknown" - DATE_TYPE="unknown" - fi - fi -} - -# Helper function to try date parsing -try_date_parse() { - local cmd_args=("$@") - local ts="" - - ts=$(LC_ALL=C "${cmd_args[@]}" 2>/dev/null) - if [[ "$ts" =~ ^[0-9]+$ ]]; then - echo "$ts" +# Get the OS-specific base directory for the Chainguard cache. +_get_cache_base_dir() { + if [[ "$(uname)" == "Darwin" ]]; then + echo "${HOME}/Library/Caches/chainguard" else - echo "" - fi -} - -# Convert a date string to a Unix timestamp -# Input date string format e.g., "2025-06-30 12:30:05 -0700 PDT" -tounix() { - local date_str="$1" - local ts="" - detect_date_type - - # Strip timezone abbreviation (e.g., BST, PDT) as it can sometimes confuse date parsers - local cleaned_date_str="${date_str% [A-Z][A-Z][A-Z]}" - - # Treat unknown date type like coreutils. Works for uutils. - if [[ "$DATE_TYPE" == "coreutils" ]] || [[ "$DATE_TYPE" == "unknown" ]]; then - ts=$(try_date_parse date --date="$cleaned_date_str" "+%s") - if [[ -z "$ts" ]]; then - # If stripping failed, try with the original string. - ts=$(try_date_parse date --date="$date_str" "+%s") - fi - elif [[ "$DATE_TYPE" == "busybox" ]]; then - ts=$(try_date_parse date -d "$cleaned_date_str" "+%s") - if [[ -z "$ts" ]]; then - # If stripping failed, try with the original string. - ts=$(try_date_parse date -d "$date_str" "+%s") - fi - elif [[ "$DATE_TYPE" == "bsd" ]]; then - # BSD date (macOS specific): requires -j and -f for parsing arbitrary date strings. - # Try original string, with full format, BSD needs exact format match - ts=$(try_date_parse date -jf "%Y-%m-%d %H:%M:%S %z %Z" "$date_str" "+%s") - if [[ -z "$ts" ]]; then - # Try cleaned string with simpler format (fallback for missing timezone) - ts=$(try_date_parse date -jf "%Y-%m-%d %H:%M:%S %z" "$cleaned_date_str" "+%s") - fi - fi - - # If 'ts' is empty, catch and report an error. - if [[ -z "$ts" ]]; then - echo "✗ ERROR! Failed to parse date string '$date_str' to Unix timestamp." >&2 - echo "⚑ DEBUG! Date parsing failed using: ${DATE_LABEL}" >&2 - return 1 + echo "${HOME}/.cache/chainguard" fi - echo "$ts" } -# Get the token TTL and populate CURRENT_TOKEN_TTL_SEC -get_token_ttl() { +# Get TTL from a token file. +_get_ttl_from_file() { local audience="$1" - CURRENT_TOKEN_TTL_SEC=0 - local now_ts - now_ts=$(date "+%s") + local token_filename="$2" + local ttl=0 - local status_json - # Get token expiry from chainctl auth status. If the token has expired, this - # will trigger a re-authentication so the output can not be suppressed. - status_json=$(chainctl auth status --output=json --audience="$audience" || echo "{}") + local cache_base_dir + cache_base_dir=$(_get_cache_base_dir) - local expiry_date_str - expiry_date_str=$(echo "$status_json" | jq -r .expiry 2>/dev/null) + # Sanitize the audience to create a safe directory name, just as 'chainctl' does. + local safe_audience="${audience//\//-}" + local token_file="${cache_base_dir}/${safe_audience}/${token_filename}" - if [[ -n "$expiry_date_str" && "$expiry_date_str" != "null" ]]; then - local expiry_ts - expiry_ts=$(tounix "$expiry_date_str") - if [[ $? -eq 0 && -n "$expiry_ts" ]]; then - CURRENT_TOKEN_TTL_SEC=$((expiry_ts - now_ts)) - # Ensure TTL is not negative - if [[ $CURRENT_TOKEN_TTL_SEC -lt 0 ]]; then CURRENT_TOKEN_TTL_SEC=0; fi - fi - fi -} + if [[ -f "$token_file" ]]; then + local token_data + token_data=$(cat "$token_file") + local expiry_ts_str="" -# Get the refresh TTL and populate CURRENT_REFRESH_TTL_SEC -get_refresh_ttl() { - local audience="$1" - CURRENT_REFRESH_TTL_SEC=0 - local now_ts - now_ts=$(date "+%s") - - # Cache directory for chainctl refresh tokens - local CHAINCTL_CACHE_DIR="${HOME}/.cache/chainguard" - if [[ "$(uname)" == "Darwin" ]]; then - CHAINCTL_CACHE_DIR="${HOME}/Library/Caches/chainguard" - fi - # Get refresh token expiry from cache file - # Sanitize audience for use in file path (replace / with -) - local audsafe="${audience//\//-}" - local refresh_token_file="${CHAINCTL_CACHE_DIR}/${audsafe}/refresh-token" - if [[ -f "$refresh_token_file" ]]; then - local refresh_data_b64 - refresh_data_b64=$(cat "$refresh_token_file") - local refresh_exp_ts_str - # Suppress stderr from base64/jq if file is malformed - refresh_exp_ts_str=$(echo "$refresh_data_b64" | base64 -d 2>/dev/null | jq -r .exp 2>/dev/null) + # Decode the JWT payload and extract the 'exp' (expiration time) claim. + expiry_ts_str=$(echo "$token_data" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq -r .exp 2>/dev/null) - if [[ -n "$refresh_exp_ts_str" && "$refresh_exp_ts_str" != "null" && "$refresh_exp_ts_str" =~ ^[0-9]+$ ]]; then - # The .exp field in refresh token is already a Unix timestamp - CURRENT_REFRESH_TTL_SEC=$((refresh_exp_ts_str - now_ts)) + if [[ -n "$expiry_ts_str" && "$expiry_ts_str" != "null" && "$expiry_ts_str" =~ ^[0-9]+$ ]]; then + ttl=$((expiry_ts_str - $(date "+%s"))) # Ensure TTL is not negative - if [[ $CURRENT_REFRESH_TTL_SEC -lt 0 ]]; then CURRENT_REFRESH_TTL_SEC=0; fi + if [[ $ttl -lt 0 ]]; then ttl=0; fi fi fi + echo "$ttl" } +# Get the token and refresh TTLs for a given audience. +# Outputs two space-separated values: get_current_ttls() { local audience="$1" - get_token_ttl "$audience" - get_refresh_ttl "$audience" + local token_ttl + token_ttl=$(_get_ttl_from_file "$audience" "oidc-token") + local refresh_ttl + refresh_ttl=$(_get_ttl_from_file "$audience" "refresh-token") + echo "$token_ttl $refresh_ttl" } logout_audience() { @@ -201,33 +107,45 @@ logout_audience() { refresh_audience() { local audience="$1" - get_current_ttls "$audience" - # Determine if any token refresh is needed - if [[ $CURRENT_TOKEN_TTL_SEC -lt $TTL_THRESHOLD_SEC || $CURRENT_REFRESH_TTL_SEC -lt $TTL_THRESHOLD_SEC ]]; then - # If the refresh token's remaining life is less than the refresh threshold, - # logout first and login to obtain a new, potentially longer-lived refresh token. - if [[ $CURRENT_REFRESH_TTL_SEC -lt $TTL_THRESHOLD_SEC ]]; then - echo "◍ $audience: Refresh token TTL ($((CURRENT_REFRESH_TTL_SEC / 60)) mins) is less than desired new token TTL ($((TTL_THRESHOLD_SEC / 60)) mins)." - echo "↻ Logging out and logging in to refresh tokens." - # Suppress error if logout fails, as it's not critical. - chainctl auth logout --audience="$audience" >/dev/null 2>&1 || true - if chainctl auth login --audience="$audience" >/dev/null 2>&1; then - echo "✔ Authenticated." + local token_ttl_sec refresh_ttl_sec + read -r token_ttl_sec refresh_ttl_sec < <(get_current_ttls "$audience") + + # Determine if any token refresh is needed. + if [[ $token_ttl_sec -lt $TTL_THRESHOLD_SEC || $refresh_ttl_sec -lt $TTL_THRESHOLD_SEC ]]; then + # If the refresh token's TTL is below the threshold, we need a full re-authentication. + if [[ $refresh_ttl_sec -lt $TTL_THRESHOLD_SEC ]]; then + if [[ $refresh_ttl_sec -eq 0 ]]; then + echo "◍ $audience not logged in or refresh token expired." else - echo "✗ ERROR! Failed to reauthenticate." + echo "◍ $audience refresh token TTL ($((refresh_ttl_sec / 60)) mins) is low." + fi + # Logout first to ensure a clean state. Suppress error as it's not critical. + chainctl auth logout --audience="$audience" >/dev/null 2>&1 || true + echo "↻ $audience performing full re-authentication..." + + # Build the login command. In interactive mode, redirect output to hide browser messages. + local login_cmd="chainctl auth login --audience=\"$audience\"" + if [[ "${HEADLESS}" != "yes" ]]; then + login_cmd+=" >/dev/null 2>&1" + fi + if ! eval "$login_cmd"; then + echo "✗ ERROR! Failed to reauthenticate $audience" >&2 + return 1 fi else - echo -n "♽ Refreshing token " - if chainctl auth login --audience="$audience" >/dev/null 2>&1; then - echo -n "✔ " - else - echo "✗ ERROR! Failed to refresh token." + # The access token needs a simple, non-interactive refresh. + echo "♽ $audience refreshing token... " + if ! chainctl auth login --audience="$audience" >/dev/null 2>&1; then + echo "✗ ERROR! Failed to refresh token for $audience." + return 1 fi fi - get_current_ttls "$audience" - echo "TTL is $((CURRENT_TOKEN_TTL_SEC / 60)) mins with a refresh TTL of $((CURRENT_REFRESH_TTL_SEC / 60)) mins: $audience" + # Re-fetch TTLs to report the new values. + read -r token_ttl_sec refresh_ttl_sec < <(get_current_ttls "$audience") + echo "✔ $audience refreshed. New TTL is $((token_ttl_sec / 60)) mins. Refresh TTL is $((refresh_ttl_sec / 60)) mins" else - echo "✔ TTL is $((CURRENT_TOKEN_TTL_SEC / 60)) mins. Refresh threshold is $((TTL_THRESHOLD_SEC / 60)) mins: $audience" + # Tokens are fine, no action needed. + echo "✔ TTL is $((token_ttl_sec / 60)) mins. Refresh TTL is $((refresh_ttl_sec / 60)) mins: $audience" fi } @@ -253,9 +171,13 @@ option_error() { # Validate and normalize TTL threshold value validate_ttl_threshold() { local ttl_minutes="$1" - local note_prefix="⚑ NOTE! TTL threshold of ${ttl_minutes}m is" - [[ $ttl_minutes -lt 5 ]] && echo "${note_prefix} too low, capping to 5 mins." >&2 && ttl_minutes=5 - [[ $ttl_minutes -gt 60 ]] && echo "${note_prefix} too high, capping to 60 mins." >&2 && ttl_minutes=60 + if [[ $ttl_minutes -lt 5 ]]; then + echo "⚑ NOTE! TTL threshold of ${ttl_minutes}m is too low, capping to 5 mins." >&2 + ttl_minutes=5 + elif [[ $ttl_minutes -gt 60 ]]; then + echo "⚑ NOTE! TTL threshold of ${ttl_minutes}m is too high, capping to 60 mins." >&2 + ttl_minutes=60 + fi echo $((ttl_minutes * 60)) } @@ -269,9 +191,10 @@ while [[ $# -gt 0 ]]; do echo "${VERSION}" exit 0;; --headless) + [[ -z "$2" ]] && option_error "--headless requires an argument: 'yes' or 'no'." case "${2,,}" in yes|no) HEADLESS="${2,,}"; shift 2;; - *) option_error "--headless requires an argument: 'yes' or 'no'.";; + *) option_error "Invalid argument for --headless: '$2'. Use 'yes' or 'no'.";; esac;; --ttl-threshold) [[ -n "$2" && "$2" =~ ^[0-9]+$ ]] || option_error "--ttl-threshold requires a numeric argument." @@ -285,9 +208,9 @@ while [[ $# -gt 0 ]]; do esac done -case "${HEADLESS}" in - no) chainctl config unset auth.mode >/dev/null 2>&1;; - *) chainctl config set auth.mode headless >/dev/null 2>&1 -esac +# Set auth mode via an environment variable to avoid changing global config. +if [[ "${HEADLESS}" == "yes" ]]; then + export CHAINGUARD_AUTH_MODE=headless +fi process_audiences