#!/usr/bin/env bash # oauth.sh — OAuth login flow against Claude.ai for Larry-Anywhere. # # Uses the same OAuth client/flow Anthropic's Claude Code CLI uses, so calls # bill against your Claude Max / Pro subscription quota instead of pay-as-you-go # API metering. Public client_id; PKCE; out-of-band code paste (no localhost # server required, works behind any firewall). # # Subcommands: # login start the auth flow; print URL; prompt for code # refresh refresh the access token using the stored refresh token # ensure print a valid access token (auto-refreshes if near-expired) # status show current auth state + expiry # debug dump full OAuth diagnostic state (file, parsed times, jq path, # cygpath translation, truncated token previews) — safe to share # logout delete the stored tokens # # Storage: $LARRY_HOME/.oauth.json (mode 0600) # # Env vars: # LARRY_OAUTH_DEBUG=1 emit per-call decision trace from cmd_ensure to stderr. # Default off so the chat log stays clean; flip on when # diagnosing why ensure returns empty. # # This is community/unofficial use of Anthropic's OAuth flow. Anthropic could # tighten it at any time. If OAuth stops working, Larry transparently falls # back to the API-key path stored in $LARRY_HOME/.env. set -u set -o pipefail LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" OAUTH_FILE="$LARRY_HOME/.oauth.json" # Anthropic Claude Code's publicly-visible OAuth client_id. Used by claude-code # and several community CLI tools (droidrun/mobilerun, motiful/cc-gateway, ...). # # Endpoints migrated 2025: claude.ai/oauth/authorize → claude.com/cai/oauth/authorize, # console.anthropic.com/v1/oauth/token → platform.claude.com/v1/oauth/token, # console.anthropic.com/oauth/code/callback → platform.claude.com/oauth/code/callback. # The OLD endpoints return a misleading "rate_limit_error" for any request. # Scopes also expanded with user:sessions:claude_code, user:mcp_servers, # user:file_upload — required by the new flow. CLIENT_ID="${LARRY_OAUTH_CLIENT_ID:-9d1c250a-e61b-44d9-88ed-5944d1962f5e}" AUTHORIZE_URL="${LARRY_OAUTH_AUTHORIZE_URL:-https://claude.com/cai/oauth/authorize}" TOKEN_URL="${LARRY_OAUTH_TOKEN_URL:-https://platform.claude.com/v1/oauth/token}" REDIRECT_URI="${LARRY_OAUTH_REDIRECT_URI:-https://platform.claude.com/oauth/code/callback}" SCOPE="${LARRY_OAUTH_SCOPE:-org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload}" die() { printf 'oauth: %s\n' "$*" >&2; exit 1; } # Dependency check command -v curl >/dev/null 2>&1 || die "curl required" command -v jq >/dev/null 2>&1 || die "jq required" command -v openssl >/dev/null 2>&1 || die "openssl required (for PKCE sha256)" b64url() { base64 | tr '/+' '_-' | tr -d '=' | tr -d '\n'; } # jqf — run jq against a file, but pipe the file via stdin so bash handles # the path translation. Needed because on MobaXterm/Cygwin the bundled jq # may be a Windows-native binary that doesn't understand Cygwin paths like # /home/mobaxterm/... when they come in as argv. Stdin redirection always # works because bash does the open() itself. # Usage: jqf jqf() { local file="$1"; shift jq "$@" < "$file" } urlenc() { # Minimal RFC3986-ish URL encoder for the bits we need (spaces, /, :) local s="$1" s="${s// /%20}" s="${s//:/%3A}" s="${s//\//%2F}" printf '%s' "$s" } gen_pkce() { local verifier challenge verifier=$(LC_ALL=C tr -dc 'a-zA-Z0-9-._~' # (Anthropic uses a URL fragment, not a query param, to deliver them.) Copy the WHOLE string — both halves and the '#' between them. 4. Paste it here: EOF printf 'authorization code (CODE#STATE): ' read -r code_input [ -z "$code_input" ] && die "no code entered" # Split CODE#STATE. If the user pasted only the code (no '#'), keep the # state we generated; otherwise verify the returned state matches. local code returned_state if [[ "$code_input" == *"#"* ]]; then code="${code_input%%#*}" returned_state="${code_input#*#}" if [ -n "$returned_state" ] && [ "$returned_state" != "$state" ]; then die "state mismatch — got '$returned_state', expected '$state' (possible CSRF or stale URL; rerun login)" fi else code="$code_input" fi local resp resp=$(curl -sS -X POST "$TOKEN_URL" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "anthropic-beta: oauth-2025-04-20" \ -H "User-Agent: claude-cli/2.1.85 (larry-anywhere)" \ -d "$(jq -n \ --arg cid "$CLIENT_ID" \ --arg code "$code" \ --arg verifier "$verifier" \ --arg redirect "$REDIRECT_URI" \ --arg state "$state" \ '{client_id:$cid, grant_type:"authorization_code", code:$code, code_verifier:$verifier, redirect_uri:$redirect, state:$state}')") if ! printf '%s' "$resp" | jq -e '.access_token' >/dev/null 2>&1; then printf '\nauth failed. server response:\n' >&2 printf '%s\n' "$resp" | jq . >&2 2>/dev/null || printf '%s\n' "$resp" >&2 cat >&2 < "$OAUTH_FILE.new" || { rm -f "$OAUTH_FILE.new" die "failed to write oauth token (jq pipeline error)" } if ! jq -e '.access_token and .refresh_token and .fetched_at' < "$OAUTH_FILE.new" >/dev/null 2>&1; then rm -f "$OAUTH_FILE.new" die "wrote oauth file is missing access_token/refresh_token/fetched_at — aborting" fi mv "$OAUTH_FILE.new" "$OAUTH_FILE" chmod 600 "$OAUTH_FILE" printf '\n✓ logged in. Tokens saved to %s (mode 0600).\n' "$OAUTH_FILE" cmd_status } cmd_refresh() { [ -f "$OAUTH_FILE" ] || die "no oauth file at $OAUTH_FILE — run 'larry-auth.sh login' first" local refresh_token; refresh_token=$(jqf "$OAUTH_FILE" -r '.refresh_token // empty') [ -n "$refresh_token" ] || die "no refresh_token in $OAUTH_FILE — please run login again" local resp resp=$(curl -sS -X POST "$TOKEN_URL" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "anthropic-beta: oauth-2025-04-20" \ -H "User-Agent: claude-cli/2.1.85 (larry-anywhere)" \ -d "$(jq -n --arg cid "$CLIENT_ID" --arg rt "$refresh_token" \ '{client_id:$cid, grant_type:"refresh_token", refresh_token:$rt}')") if ! printf '%s' "$resp" | jq -e '.access_token' >/dev/null 2>&1; then printf 'refresh failed:\n%s\n' "$resp" >&2 return 1 fi local now; now=$(date +%s) # Pre-read the old refresh_token so we don't need --slurpfile (which would # take a file path argv-style and break on MobaXterm's Windows-native jq). local prev_refresh; prev_refresh=$(jqf "$OAUTH_FILE" -r '.refresh_token // empty') if ! printf '%s' "$resp" \ | jq --arg now "$now" --arg prev "$prev_refresh" \ '. + {fetched_at: ($now|tonumber), refresh_token: (.refresh_token // $prev)}' \ > "$OAUTH_FILE.new"; then rm -f "$OAUTH_FILE.new" printf 'refresh: failed to write new oauth file (jq pipeline error)\n' >&2 return 1 fi # Validate before clobbering the existing file. If validation fails the old # token file stays intact and ensure can still serve the soon-to-expire token. if ! jq -e '.access_token and .refresh_token and .fetched_at' < "$OAUTH_FILE.new" >/dev/null 2>&1; then rm -f "$OAUTH_FILE.new" printf 'refresh: new oauth file is missing required keys — keeping previous file intact\n' >&2 return 1 fi mv "$OAUTH_FILE.new" "$OAUTH_FILE" chmod 600 "$OAUTH_FILE" jqf "$OAUTH_FILE" -r '.access_token' } # dbg — gated decision-trace logger. Only fires when LARRY_OAUTH_DEBUG=1 is # set in the env. Goes to stderr so it doesn't pollute the token printed on # stdout. Caller of `oauth.sh ensure` should capture stderr and surface it # if the token is empty. dbg() { [ "${LARRY_OAUTH_DEBUG:-0}" = "1" ] || return 0 printf 'oauth.dbg: %s\n' "$*" >&2 } cmd_ensure() { if [ ! -f "$OAUTH_FILE" ]; then dbg "ensure: $OAUTH_FILE does not exist — returning empty" printf 'ensure: no oauth file at %s\n' "$OAUTH_FILE" >&2 return 1 fi # Sanity-check the file actually parses as JSON. If it doesn't (truncated, # half-written, mid-disk-full), every jqf below silently returns empty which # propagates as "token unavailable" with zero diagnostic info upstream. if ! jq -e . < "$OAUTH_FILE" >/dev/null 2>&1; then dbg "ensure: $OAUTH_FILE failed to parse as JSON — corrupted?" printf 'ensure: %s is not valid JSON (corrupted? rerun login)\n' "$OAUTH_FILE" >&2 return 1 fi local fetched_at expires_in fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0') expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600') local now; now=$(date +%s) local expires_at=$((fetched_at + expires_in)) local left=$((expires_at - now)) dbg "ensure: fetched_at=$fetched_at expires_in=$expires_in now=$now expires_at=$expires_at left=${left}s" if [ "$now" -ge $((expires_at - 300)) ]; then dbg "ensure: within 300s of expiry — refreshing" if ! cmd_refresh >/dev/null 2>&1; then dbg "ensure: cmd_refresh failed; calling again with stderr exposed for trace" # Re-run with stderr visible so the upstream call_api capture sees WHY. cmd_refresh >/dev/null || true printf 'ensure: refresh failed (see above)\n' >&2 return 1 fi dbg "ensure: refresh OK — emitting new access_token" jqf "$OAUTH_FILE" -r '.access_token' else dbg "ensure: token still valid — emitting cached access_token" local tok; tok=$(jqf "$OAUTH_FILE" -r '.access_token // empty') if [ -z "$tok" ]; then dbg "ensure: .access_token is empty in the file" printf 'ensure: .access_token missing from %s\n' "$OAUTH_FILE" >&2 return 1 fi printf '%s' "$tok" fi } cmd_status() { if [ ! -f "$OAUTH_FILE" ]; then echo "OAuth: not authenticated (no $OAUTH_FILE)" return 1 fi local fetched_at expires_in scope fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0') expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600') scope=$(jqf "$OAUTH_FILE" -r '.scope // "(unknown)"') local now; now=$(date +%s) local expires_at=$((fetched_at + expires_in)) local left=$((expires_at - now)) printf 'OAuth status:\n' printf ' file: %s\n' "$OAUTH_FILE" printf ' scope: %s\n' "$scope" printf ' fetched_at: %s\n' "$(date -r "$fetched_at" 2>/dev/null || date -d "@$fetched_at" 2>/dev/null)" printf ' expires_in: %d s\n' "$expires_in" if [ "$left" -le 0 ]; then printf ' state: EXPIRED (%ds ago) — will auto-refresh on next call\n' "$((-left))" else printf ' state: valid for %d more seconds (~%d min)\n' "$left" "$((left/60))" fi } # cmd_debug — dump everything Mack would need to figure out why ensure is # returning empty. SAFE to share: token previews are truncated to first 20 # chars only. Goes to stdout (so the user can copy-paste). cmd_debug() { printf '=== Larry OAuth diagnostic ===\n' printf 'LARRY_HOME=%s\n' "$LARRY_HOME" printf 'OAUTH_FILE=%s\n' "$OAUTH_FILE" # jq binary identity — helps distinguish Cygwin jq from Windows-native jq # when paths look weird on MobaXterm. printf '\n[jq]\n' local jq_path; jq_path=$(command -v jq 2>/dev/null || true) printf ' path: %s\n' "${jq_path:-(not found)}" if [ -n "$jq_path" ]; then printf ' version: %s\n' "$(jq --version 2>&1 | head -n1)" # On Cygwin, `file` may not exist but the path tells us if it's a .exe. case "$jq_path" in *.exe) printf ' flavor: Windows-native (.exe — uses Windows paths in argv)\n' ;; *) printf ' flavor: Unix-style (POSIX paths in argv)\n' ;; esac fi # cygpath translation — only relevant on Cygwin/MobaXterm. If present, show # how the OAUTH_FILE looks to a Windows binary. if command -v cygpath >/dev/null 2>&1; then printf '\n[cygpath]\n' printf ' unix: %s\n' "$OAUTH_FILE" printf ' windows: %s\n' "$(cygpath -w "$OAUTH_FILE" 2>&1 || echo '(cygpath failed)')" fi # File state printf '\n[file]\n' if [ ! -f "$OAUTH_FILE" ]; then printf ' status: MISSING — run larry-auth.sh login\n' return 0 fi # Mode, size, mtime — across mac (BSD stat) and linux (GNU stat) local stat_line stat_line=$(stat -f 'mode=%Sp size=%z mtime=%Sm' "$OAUTH_FILE" 2>/dev/null \ || stat -c 'mode=%A size=%s mtime=%y' "$OAUTH_FILE" 2>/dev/null \ || echo '(stat unavailable)') printf ' %s\n' "$stat_line" if ! jq -e . < "$OAUTH_FILE" >/dev/null 2>&1; then printf ' status: CORRUPTED — not valid JSON\n' printf ' first 80 bytes (sanitized): ' head -c 80 "$OAUTH_FILE" | tr -d '\n' | head -c 80 printf '\n' return 0 fi printf ' status: parses as JSON\n' # Token math printf '\n[token math]\n' local fetched_at expires_in scope fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0') expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600') scope=$(jqf "$OAUTH_FILE" -r '.scope // "(missing)"') local now; now=$(date +%s) local expires_at=$((fetched_at + expires_in)) local left=$((expires_at - now)) local refresh_at=$((expires_at - 300)) local would_refresh="NO" [ "$now" -ge "$refresh_at" ] && would_refresh="YES (within 300s of expiry)" # Human-readable timestamps that work on both BSD and GNU date. local fetched_human expires_human now_human fetched_human=$(date -r "$fetched_at" 2>/dev/null || date -d "@$fetched_at" 2>/dev/null || echo "(date unavailable)") expires_human=$(date -r "$expires_at" 2>/dev/null || date -d "@$expires_at" 2>/dev/null || echo "(date unavailable)") now_human=$(date 2>/dev/null || echo "(date unavailable)") printf ' fetched_at: %s (%s)\n' "$fetched_at" "$fetched_human" printf ' expires_in: %s seconds\n' "$expires_in" printf ' expires_at: %s (%s)\n' "$expires_at" "$expires_human" printf ' now: %s (%s)\n' "$now" "$now_human" printf ' seconds_left: %s (~%d min)\n' "$left" "$((left/60))" printf ' would_refresh: %s\n' "$would_refresh" printf ' scope: %s\n' "$scope" # Token previews — first 20 chars only, safe to share for "is it present" printf '\n[tokens (truncated)]\n' local at rt at=$(jqf "$OAUTH_FILE" -r '.access_token // empty') rt=$(jqf "$OAUTH_FILE" -r '.refresh_token // empty') if [ -z "$at" ]; then printf ' access_token: (EMPTY/MISSING)\n' else printf ' access_token: %s... (len=%d)\n' "$(printf '%s' "$at" | head -c 20)" "${#at}" fi if [ -z "$rt" ]; then printf ' refresh_token: (EMPTY/MISSING)\n' else printf ' refresh_token: %s... (len=%d)\n' "$(printf '%s' "$rt" | head -c 20)" "${#rt}" fi # Live ensure dry-run with stderr exposed so we see every decision. printf '\n[live ensure trace]\n' printf ' Running: LARRY_OAUTH_DEBUG=1 cmd_ensure (stderr below, stdout suppressed)\n' local trace_token trace_err trace_rc trace_err=$(mktemp) trace_token=$(LARRY_OAUTH_DEBUG=1 cmd_ensure 2>"$trace_err"); trace_rc=$? printf ' exit_code: %d\n' "$trace_rc" printf ' token_emitted: %s\n' "$([ -n "$trace_token" ] && echo "YES (len=${#trace_token})" || echo "NO (empty)")" printf ' stderr trace:\n' sed 's/^/ /' "$trace_err" rm -f "$trace_err" printf '\n=== end diagnostic ===\n' } cmd_logout() { if [ -f "$OAUTH_FILE" ]; then rm -f "$OAUTH_FILE" echo "logged out (removed $OAUTH_FILE)" else echo "no token file to remove" fi } case "${1:-status}" in login) cmd_login ;; refresh) cmd_refresh ;; ensure) cmd_ensure ;; status) cmd_status ;; debug) cmd_debug ;; logout) cmd_logout ;; -h|--help|help) sed -n '2,30p' "$0" ;; *) die "unknown subcommand: ${1:-} (try 'login|refresh|ensure|status|debug|logout')" ;; esac