Each workstation user needs a continuously valid Claude token under their own Enterprise identity. Store only that user's OAuth state in an isolated Vault path, renew and verify it automatically, recover from Vault when possible, and alert when interactive SSO is required.
153 lines
5.5 KiB
Bash
Executable file
153 lines
5.5 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Keep one Workstation user's Claude subscription OAuth credentials recoverable.
|
|
# Claude owns access/refresh-token rotation in ~/.claude/.credentials.json. This
|
|
# helper validates auth with real inference, stores only the claudeAiOauth object
|
|
# in the user's isolated Vault path, and attempts one restore on failure.
|
|
set -euo pipefail
|
|
|
|
CAS_USER="${CLAUDE_AUTH_USER:-$(id -un)}"
|
|
CAS_HOME="${HOME:?HOME must be set}"
|
|
CAS_CREDENTIALS="${CLAUDE_CREDENTIALS_FILE:-$CAS_HOME/.claude/.credentials.json}"
|
|
CAS_CONFIG_DIR="${CLAUDE_AUTH_CONFIG_DIR:-$CAS_HOME/.config/claude-auth-sync}"
|
|
CAS_VAULT_TOKEN_FILE="${CLAUDE_AUTH_VAULT_TOKEN_FILE:-$CAS_CONFIG_DIR/vault-token}"
|
|
CAS_VAULT_PATH="${CLAUDE_AUTH_VAULT_PATH:-secret/workstation/claude-users/$CAS_USER}"
|
|
CAS_STATE_DIR="${CLAUDE_AUTH_STATE_DIR:-$CAS_HOME/.local/state/claude-auth-sync}"
|
|
CAS_LOG="$CAS_STATE_DIR/sync.log"
|
|
|
|
cas_log() {
|
|
mkdir -p "$CAS_STATE_DIR"
|
|
printf '%s %s\n' "$(date -Is)" "$*" >> "$CAS_LOG"
|
|
logger -t claude-auth-sync -- "user=$CAS_USER $*" 2>/dev/null || true
|
|
}
|
|
|
|
# Print the Claude OAuth object, or fail without exposing any token material.
|
|
cas_oauth_from_credentials() {
|
|
jq -ce '.claudeAiOauth
|
|
| select((.accessToken | type) == "string" and (.accessToken | length) > 0)
|
|
| select((.refreshToken | type) == "string" and (.refreshToken | length) > 0)
|
|
| select((.expiresAt | type) == "number")' "$1"
|
|
}
|
|
|
|
# Merge a recovered OAuth object while preserving unrelated credentials (MCP OAuth).
|
|
cas_merge_oauth() {
|
|
local credentials="$1" oauth="$2"
|
|
jq -ce --argjson oauth "$oauth" '.claudeAiOauth = $oauth' "$credentials"
|
|
}
|
|
|
|
cas_vault_identity_ok() {
|
|
local display_name="$1" policies_csv="$2"
|
|
[[ "$display_name" == "token-devvm-claude-auth-$CAS_USER" ]] || return 1
|
|
printf ',%s,' "$policies_csv" | grep -q ",workstation-claude-$CAS_USER,"
|
|
}
|
|
|
|
cas_prepare_vault() {
|
|
[[ -s "$CAS_VAULT_TOKEN_FILE" ]] || {
|
|
cas_log "FAIL missing scoped Vault token; admin must run workstation provisioning"
|
|
return 1
|
|
}
|
|
export VAULT_ADDR="${VAULT_ADDR:-https://vault.viktorbarzin.me}"
|
|
VAULT_TOKEN="$(<"$CAS_VAULT_TOKEN_FILE")"; export VAULT_TOKEN
|
|
|
|
local info display_name policies
|
|
info="$(vault token lookup -format=json 2>/dev/null)" || {
|
|
cas_log "FAIL scoped Vault token lookup failed"
|
|
return 1
|
|
}
|
|
display_name="$(jq -r '.data.display_name // ""' <<<"$info")"
|
|
policies="$(jq -r '((.data.policies // []) + (.data.identity_policies // [])) | join(",")' <<<"$info")"
|
|
cas_vault_identity_ok "$display_name" "$policies" || {
|
|
cas_log "FAIL scoped Vault token drift detected; refusing foreign token"
|
|
return 1
|
|
}
|
|
vault token renew -format=json >/dev/null 2>&1 || {
|
|
cas_log "FAIL scoped Vault token renewal failed"
|
|
return 1
|
|
}
|
|
}
|
|
|
|
# auth status is not authoritative: it reported loggedIn=true during a real 401
|
|
# on 2026-06-20. A tiny, non-persistent inference is the feedback loop.
|
|
cas_live_auth_ok() {
|
|
local out
|
|
out="$(timeout 60 claude -p 'Reply with exactly AUTH_OK and nothing else.' \
|
|
--model haiku --max-turns 1 --no-session-persistence --tools "" \
|
|
--disable-slash-commands --setting-sources "" 2>/dev/null)" || return 1
|
|
[[ "$out" == "AUTH_OK" ]]
|
|
}
|
|
|
|
cas_backup() {
|
|
local oauth expires
|
|
oauth="$(cas_oauth_from_credentials "$CAS_CREDENTIALS")" || {
|
|
cas_log "FAIL local Claude OAuth credential is absent or malformed"
|
|
return 1
|
|
}
|
|
expires="$(jq -r '.expiresAt' <<<"$oauth")"
|
|
vault kv put "$CAS_VAULT_PATH" \
|
|
claude_ai_oauth_json="$oauth" \
|
|
credential_expires_at_ms="$expires" \
|
|
backed_up_at="$(date -Is)" >/dev/null || {
|
|
cas_log "FAIL Vault credential backup failed"
|
|
return 1
|
|
}
|
|
cas_log "OK Claude auth valid; refreshed OAuth state backed up to Vault"
|
|
}
|
|
|
|
cas_restore() {
|
|
local oauth base tmp
|
|
oauth="$(vault kv get -field=claude_ai_oauth_json "$CAS_VAULT_PATH" 2>/dev/null)" || {
|
|
cas_log "FAIL no recoverable Claude OAuth credential in Vault"
|
|
return 1
|
|
}
|
|
jq -e 'select((.accessToken | type) == "string" and (.accessToken | length) > 0)
|
|
| select((.refreshToken | type) == "string" and (.refreshToken | length) > 0)
|
|
| select((.expiresAt | type) == "number")' <<<"$oauth" >/dev/null || {
|
|
cas_log "FAIL Vault Claude OAuth credential is malformed"
|
|
return 1
|
|
}
|
|
|
|
mkdir -p "$(dirname "$CAS_CREDENTIALS")"
|
|
if jq -e 'type == "object"' "$CAS_CREDENTIALS" >/dev/null 2>&1; then
|
|
base="$CAS_CREDENTIALS"
|
|
else
|
|
base="$(mktemp)"; printf '{}\n' > "$base"
|
|
fi
|
|
tmp="$(mktemp "${CAS_CREDENTIALS}.XXXXXX")"
|
|
if ! cas_merge_oauth "$base" "$oauth" > "$tmp"; then
|
|
rm -f "$tmp"; [[ "$base" == "$CAS_CREDENTIALS" ]] || rm -f "$base"
|
|
cas_log "FAIL could not merge Vault Claude OAuth credential"
|
|
return 1
|
|
fi
|
|
chmod 0600 "$tmp"
|
|
mv "$tmp" "$CAS_CREDENTIALS"
|
|
[[ "$base" == "$CAS_CREDENTIALS" ]] || rm -f "$base"
|
|
cas_log "RECOVERED restored Claude OAuth state from Vault"
|
|
}
|
|
|
|
cas_main() {
|
|
umask 077
|
|
for bin in jq vault claude timeout flock; do
|
|
command -v "$bin" >/dev/null || { cas_log "FAIL missing dependency: $bin"; return 1; }
|
|
done
|
|
mkdir -p "$CAS_STATE_DIR"
|
|
exec 9>"$CAS_STATE_DIR/lock"
|
|
flock -n 9 || { cas_log "SKIP another sync is already running"; return 0; }
|
|
|
|
cas_prepare_vault || return 1
|
|
if cas_live_auth_ok; then
|
|
cas_backup
|
|
return
|
|
fi
|
|
|
|
cas_log "WARN live Claude auth failed; attempting one Vault restore"
|
|
cas_restore || return 1
|
|
if cas_live_auth_ok; then
|
|
cas_backup
|
|
return
|
|
fi
|
|
cas_log "FAIL Claude auth still invalid after Vault restore; interactive SSO login required"
|
|
return 1
|
|
}
|
|
|
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
|
cas_main "$@"
|
|
fi
|