Files
gstack/bin/gstack-auth
Garry Tan 03de795195 fix: simplify auth to OTP-only, remove magic link complexity
Magic link requires matching the Supabase Site URL to a dynamic local
port, which doesn't work reliably. OTP is the right UX for a CLI tool
— user is already in a terminal, typing 6 digits is fast. Removes
bun callback server, nc listener, port detection, and cleanup traps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 08:20:48 -07:00

214 lines
6.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack-auth — authenticate with Supabase via email OTP
#
# Usage:
# gstack-auth [email] — start auth flow (prompts if no email)
# gstack-auth status — show current auth status
# gstack-auth logout — remove saved tokens
#
# 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
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:-}"
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" <<TOKJSON
{
"access_token": "${access_token}",
"refresh_token": "${refresh_token}",
"expires_at": ${expires_at},
"email": "${email}",
"user_id": "${user_id}"
}
TOKJSON
chmod 600 "$AUTH_FILE"
}
# ─── Helper: extract JSON field (portable, no jq dependency) ─
json_field() {
local json="$1"
local field="$2"
echo "$json" | grep -o "\"${field}\":[^,}]*" | head -1 | sed "s/\"${field}\"://;s/\"//g;s/ //g"
}
# ─── Subcommand: status ─────────────────────────────────────
if [ "${1:-}" = "status" ]; then
if [ ! -f "$AUTH_FILE" ]; then
echo "Not authenticated. Run: gstack auth <email>"
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
# ─── Main: auth flow ────────────────────────────────────────
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}"