#!/usr/bin/env bash # gstack-auth — authenticate with gstack.gg # # Usage: # gstack-auth — device code flow (default: opens browser) # gstack-auth otp [email] — email OTP flow (fallback for SSH/headless) # gstack-auth status — show current auth status # gstack-auth logout — remove saved tokens # gstack-auth change-email — change your email address # # Default flow (device code, RFC 8628): # 1. CLI requests a device code from gstack.gg # 2. Browser opens → user signs in + approves # 3. CLI polls until approved → gets Supabase tokens # # Fallback (email OTP): # Sends a 6-digit verification code to the user's email. # User enters the code in the terminal to authenticate. # # Env overrides (for testing): # GSTACK_STATE_DIR — override ~/.gstack state directory # GSTACK_DIR — override auto-detected gstack root # GSTACK_WEB_URL — override gstack.gg URL set -euo pipefail GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" AUTH_FILE="$STATE_DIR/auth-token.json" # Source Supabase config if [ -f "$GSTACK_DIR/supabase/config.sh" ]; then . "$GSTACK_DIR/supabase/config.sh" fi SUPABASE_URL="${GSTACK_SUPABASE_URL:-}" ANON_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" WEB_URL="${GSTACK_WEB_URL:-https://gstack.gg}" if [ -z "$SUPABASE_URL" ] || [ -z "$ANON_KEY" ]; then echo "Error: Supabase not configured. Check supabase/config.sh" exit 1 fi AUTH_URL="${SUPABASE_URL}/auth/v1" # ─── Helper: write auth token file ────────────────────────── save_token() { local access_token="$1" local refresh_token="$2" local expires_in="$3" local email="$4" local user_id="$5" local expires_at expires_at=$(( $(date +%s) + expires_in )) mkdir -p "$STATE_DIR" cat > "$AUTH_FILE" </dev/null | sed 's/null//' } # ─── Subcommand: status ───────────────────────────────────── if [ "${1:-}" = "status" ]; then if [ ! -f "$AUTH_FILE" ]; then echo "Not authenticated. Run: gstack auth " exit 0 fi AUTH_JSON="$(cat "$AUTH_FILE")" EMAIL="$(json_field "$AUTH_JSON" "email")" EXPIRES_AT="$(json_field "$AUTH_JSON" "expires_at")" NOW="$(date +%s)" if [ "$NOW" -lt "$EXPIRES_AT" ] 2>/dev/null; then REMAINING=$(( (EXPIRES_AT - NOW) / 60 )) echo "Authenticated as: $EMAIL" echo "Token expires in: ${REMAINING}m" else echo "Authenticated as: $EMAIL (token expired — will auto-refresh)" fi exit 0 fi # ─── Subcommand: logout ───────────────────────────────────── if [ "${1:-}" = "logout" ]; then rm -f "$AUTH_FILE" echo "Logged out. Auth token removed." exit 0 fi # ─── Subcommand: change-email ───────────────────────────────── if [ "${1:-}" = "change-email" ]; then echo "To change your email, log out and re-authenticate:" echo " gstack-auth logout" echo " gstack-auth" exit 0 fi # ─── Device code flow (default) ────────────────────────────── # If no arguments, or first arg is not 'otp', use device code flow if [ "${1:-}" != "otp" ]; then # Check if we can open a browser CAN_OPEN=false if command -v open >/dev/null 2>&1; then CAN_OPEN=true elif command -v xdg-open >/dev/null 2>&1; then CAN_OPEN=true fi if [ "$CAN_OPEN" = "false" ]; then echo "No browser available — falling back to email OTP." >&2 echo "Run: gstack-auth otp [email]" >&2 # Fall through to OTP flow set -- "otp" "${@}" else echo "" echo "Requesting device code from gstack.gg..." # Step 1: Request device code DEVICE_RESPONSE="$(curl -s -w "\n%{http_code}" \ --max-time 15 \ -X POST "${WEB_URL}/api/auth/device" \ -H "Content-Type: application/json" \ 2>/dev/null || echo -e "\n000")" DEVICE_CODE_HTTP="$(echo "$DEVICE_RESPONSE" | tail -1)" DEVICE_BODY="$(echo "$DEVICE_RESPONSE" | sed '$d')" if [ "${DEVICE_CODE_HTTP}" != "200" ]; then echo "Device auth unavailable (HTTP ${DEVICE_CODE_HTTP}). Falling back to email OTP." >&2 set -- "otp" "${@}" else DEVICE_CODE="$(json_field "$DEVICE_BODY" "device_code")" DEVICE_SECRET="$(json_field "$DEVICE_BODY" "device_secret")" USER_CODE="$(json_field "$DEVICE_BODY" "user_code")" VERIFY_URL="$(json_field "$DEVICE_BODY" "verification_url")" if [ -z "$DEVICE_CODE" ] || [ -z "$USER_CODE" ]; then echo "Error: invalid device code response" >&2 set -- "otp" "${@}" else echo "" echo "Your code: ${USER_CODE}" echo "" echo "Opening browser to approve..." echo "If the browser doesn't open, visit: ${VERIFY_URL}" echo "" # Step 2: Open browser if command -v open >/dev/null 2>&1; then open "$VERIFY_URL" 2>/dev/null elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$VERIFY_URL" 2>/dev/null fi # Step 3: Poll for approval (every 5s, max 2 minutes) echo "Waiting for approval..." POLL_COUNT=0 MAX_POLLS=24 # 24 * 5s = 2 minutes while [ "$POLL_COUNT" -lt "$MAX_POLLS" ]; do sleep 5 POLL_COUNT=$((POLL_COUNT + 1)) POLL_RESPONSE="$(curl -s -w "\n%{http_code}" \ --max-time 10 \ -X POST "${WEB_URL}/api/auth/device/token" \ -H "Content-Type: application/json" \ -d "{\"device_code\":\"${DEVICE_CODE}\",\"device_secret\":\"${DEVICE_SECRET}\"}" \ 2>/dev/null || echo -e "\n000")" POLL_HTTP="$(echo "$POLL_RESPONSE" | tail -1)" POLL_BODY="$(echo "$POLL_RESPONSE" | sed '$d')" case "$POLL_HTTP" in 200) # Approved! Extract tokens ACCESS_TOKEN="$(json_field "$POLL_BODY" "access_token")" REFRESH_TOKEN="$(json_field "$POLL_BODY" "refresh_token")" EXPIRES_IN="$(json_field "$POLL_BODY" "expires_in")" USER_ID="$(json_field "$POLL_BODY" "user_id")" EMAIL="$(json_field "$POLL_BODY" "email")" if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then echo "Error: approved but no token in response" >&2 exit 1 fi save_token "$ACCESS_TOKEN" "$REFRESH_TOKEN" "${EXPIRES_IN:-3600}" "${EMAIL:-}" "${USER_ID:-}" if [ -n "$EMAIL" ]; then "$GSTACK_DIR/bin/gstack-config" set email "$EMAIL" 2>/dev/null || true fi echo "" echo "Authenticated${EMAIL:+ as: $EMAIL}" echo "Token saved to: ${AUTH_FILE}" exit 0 ;; 202) # Still pending — keep polling printf "\r Waiting... (%ds)" "$((POLL_COUNT * 5))" ;; 403) echo "" echo "Error: invalid device secret (403). Try again." >&2 exit 1 ;; 410) echo "" echo "Device code expired. Run gstack-auth again." >&2 exit 1 ;; *) # Keep trying on transient errors ;; esac done echo "" echo "Timed out waiting for approval (2 minutes)." >&2 echo "Run gstack-auth again to get a new code." >&2 exit 1 fi fi fi fi # ─── OTP flow (fallback) ──────────────────────────────────── # Strip the "otp" subcommand if present if [ "${1:-}" = "otp" ]; then shift fi EMAIL="${1:-}" if [ -z "$EMAIL" ]; then printf "Enter your email: " read -r EMAIL fi if [ -z "$EMAIL" ]; then echo "Error: email is required" exit 1 fi if ! echo "$EMAIL" | grep -qE '^[^@]+@[^@]+\.[^@]+$'; then echo "Error: invalid email format" exit 1 fi # ─── Step 1: Send OTP ──────────────────────────────────────── echo "" echo "Sending verification code to ${EMAIL}..." OTP_BODY="{\"email\":\"${EMAIL}\"}" HTTP_RESPONSE="$(curl -s -w "\n%{http_code}" \ -X POST "${AUTH_URL}/otp" \ -H "Content-Type: application/json" \ -H "apikey: ${ANON_KEY}" \ -d "$OTP_BODY" 2>/dev/null || echo -e "\n000")" HTTP_CODE="$(echo "$HTTP_RESPONSE" | tail -1)" HTTP_BODY="$(echo "$HTTP_RESPONSE" | sed '$d')" case "$HTTP_CODE" in 2*) ;; # success 429) if echo "$HTTP_BODY" | grep -q "email_send_rate_limit"; then echo "" echo "Email rate limit exceeded (Supabase free tier: ~3 emails/hour)." echo "Try again in a few minutes, or set up custom SMTP in the Supabase" echo "dashboard for unlimited sends." exit 1 fi echo "Cooldown active — waiting 60s before retrying..." for i in $(seq 60 -1 1); do printf "\r Retrying in %2ds..." "$i" sleep 1 done printf "\r \r" echo "Retrying..." HTTP_RESPONSE="$(curl -s -w "\n%{http_code}" \ -X POST "${AUTH_URL}/otp" \ -H "Content-Type: application/json" \ -H "apikey: ${ANON_KEY}" \ -d "$OTP_BODY" 2>/dev/null || echo -e "\n000")" HTTP_CODE="$(echo "$HTTP_RESPONSE" | tail -1)" HTTP_BODY="$(echo "$HTTP_RESPONSE" | sed '$d')" case "$HTTP_CODE" in 2*) ;; # success on retry *) echo "Error sending OTP (HTTP ${HTTP_CODE}): ${HTTP_BODY}"; exit 1 ;; esac ;; *) echo "Error sending OTP (HTTP ${HTTP_CODE}): ${HTTP_BODY}" exit 1 ;; esac echo "" echo "Check your email for a 6-digit code." echo "" # ─── Step 2: Read OTP code ─────────────────────────────────── printf "Enter code: " read -r OTP_CODE if [ -z "$OTP_CODE" ]; then echo "No code entered." exit 1 fi # ─── Step 3: Verify OTP ───────────────────────────────────── OTP_CODE="$(echo "$OTP_CODE" | tr -d '[:space:]')" if ! echo "$OTP_CODE" | grep -qE '^[0-9]{6}$'; then echo "Error: code must be exactly 6 digits" exit 1 fi VERIFY_RESPONSE="$(curl -s \ -X POST "${AUTH_URL}/verify" \ -H "Content-Type: application/json" \ -H "apikey: ${ANON_KEY}" \ -d "{\"email\":\"${EMAIL}\",\"token\":\"${OTP_CODE}\",\"type\":\"email\"}" \ 2>/dev/null || echo "{}")" ACCESS_TOKEN="$(json_field "$VERIFY_RESPONSE" "access_token")" REFRESH_TOKEN="$(json_field "$VERIFY_RESPONSE" "refresh_token")" EXPIRES_IN="$(json_field "$VERIFY_RESPONSE" "expires_in")" USER_ID="$(json_field "$VERIFY_RESPONSE" "id" 2>/dev/null || true)" if [ -z "$USER_ID" ]; then USER_ID="$(echo "$VERIFY_RESPONSE" | grep -o '"id":"[^"]*"' | head -1 | sed 's/"id":"//;s/"//')" fi if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then ERROR_MSG="$(json_field "$VERIFY_RESPONSE" "error_description" 2>/dev/null || json_field "$VERIFY_RESPONSE" "msg" 2>/dev/null || echo "unknown error")" echo "" echo "Verification failed: $ERROR_MSG" echo "Check the code and try again." exit 1 fi save_token "$ACCESS_TOKEN" "$REFRESH_TOKEN" "${EXPIRES_IN:-3600}" "$EMAIL" "$USER_ID" # ─── Step 4: Save email to config ──────────────────────────── "$GSTACK_DIR/bin/gstack-config" set email "$EMAIL" echo "" echo "Authenticated as: ${EMAIL}" echo "Token saved to: ${AUTH_FILE}"