diff --git a/bin/gstack-auth b/bin/gstack-auth index 4078e781..99693a5a 100755 --- a/bin/gstack-auth +++ b/bin/gstack-auth @@ -1,15 +1,13 @@ #!/usr/bin/env bash -# gstack-auth — authenticate with Supabase via email OTP + magic link +# 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 # -# Two-path authentication: -# 1. OTP: user enters 6-digit code from email in terminal -# 2. Magic link: user clicks link → redirects to local server -# Whichever completes first wins. +# 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 @@ -65,16 +63,6 @@ json_field() { echo "$json" | grep -o "\"${field}\":[^,}]*" | head -1 | sed "s/\"${field}\"://;s/\"//g;s/ //g" } -# ─── Helper: clean up background processes ─────────────────── -cleanup() { - # Kill the local server if running - [ -n "${SERVER_PID:-}" ] && kill "$SERVER_PID" 2>/dev/null || true - # Remove temp files - [ -n "${CALLBACK_FILE:-}" ] && rm -f "$CALLBACK_FILE" 2>/dev/null || true - [ -n "${RESPONSE_FILE:-}" ] && rm -f "$RESPONSE_FILE" 2>/dev/null || true -} -trap cleanup EXIT - # ─── Subcommand: status ───────────────────────────────────── if [ "${1:-}" = "status" ]; then if [ ! -f "$AUTH_FILE" ]; then @@ -114,32 +102,16 @@ if [ -z "$EMAIL" ]; then exit 1 fi -# Validate email format (basic check) if ! echo "$EMAIL" | grep -qE '^[^@]+@[^@]+\.[^@]+$'; then echo "Error: invalid email format" exit 1 fi -# ─── Find a free port for magic link callback ──────────────── -find_free_port() { - # Try to find a free port using Python (available on macOS) - python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()' 2>/dev/null || echo "0" -} - -CALLBACK_PORT="$(find_free_port)" -CALLBACK_URL="http://localhost:${CALLBACK_PORT}/callback" -CALLBACK_FILE="$(mktemp)" -RESPONSE_FILE="$(mktemp)" - -# ─── Step 1: Send OTP (also sends magic link) ──────────────── +# ─── Step 1: Send OTP ──────────────────────────────────────── echo "" -echo "Sending verification email to ${EMAIL}..." +echo "Sending verification code to ${EMAIL}..." -# If we got a valid port, include redirect URL for magic link OTP_BODY="{\"email\":\"${EMAIL}\"}" -if [ "$CALLBACK_PORT" != "0" ]; then - OTP_BODY="{\"email\":\"${EMAIL}\",\"options\":{\"emailRedirectTo\":\"${CALLBACK_URL}\"}}" -fi HTTP_RESPONSE="$(curl -s -w "\n%{http_code}" \ -X POST "${AUTH_URL}/otp" \ @@ -147,15 +119,38 @@ HTTP_RESPONSE="$(curl -s -w "\n%{http_code}" \ -H "apikey: ${ANON_KEY}" \ -d "$OTP_BODY" 2>/dev/null || echo -e "\n000")" -HTTP_BODY="$(echo "$HTTP_RESPONSE" | head -n -1)" HTTP_CODE="$(echo "$HTTP_RESPONSE" | tail -1)" +HTTP_BODY="$(echo "$HTTP_RESPONSE" | sed '$d')" case "$HTTP_CODE" in 2*) ;; # success 429) - echo "Rate limited — please wait 60 seconds and try again." - exit 1 + 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}" @@ -164,110 +159,53 @@ case "$HTTP_CODE" in esac echo "" -echo "Check your email! Two ways to authenticate:" -echo " 1. Enter the 6-digit code below" -if [ "$CALLBACK_PORT" != "0" ]; then - echo " 2. Or click the magic link in the email" -fi +echo "Check your email for a 6-digit code." echo "" -# ─── Step 2: Start local server for magic link (background) ── -if [ "$CALLBACK_PORT" != "0" ]; then - # Start a simple HTTP listener that captures the callback - ( - # Use nc to listen for one connection - while true; do - REQUEST="$(echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n

gstack authenticated!

You can close this tab.

" | nc -l "$CALLBACK_PORT" 2>/dev/null || true)" - if echo "$REQUEST" | grep -q "GET /callback"; then - # Extract token_hash or access_token from query params - QUERY="$(echo "$REQUEST" | grep "GET /callback" | sed 's/GET \/callback?//' | awk '{print $1}')" - echo "$QUERY" > "$CALLBACK_FILE" - break - fi - done - ) & - SERVER_PID=$! -fi - -# ─── Step 3: Race OTP input vs magic link callback ─────────── +# ─── Step 2: Read OTP code ─────────────────────────────────── printf "Enter code: " +read -r OTP_CODE -# Read with 5-minute timeout -OTP_CODE="" -if read -r -t 300 OTP_CODE 2>/dev/null; then - : # Got OTP code from terminal -fi - -# Check if magic link callback arrived while we waited -MAGIC_LINK_TOKEN="" -if [ -f "$CALLBACK_FILE" ] && [ -s "$CALLBACK_FILE" ]; then - CALLBACK_DATA="$(cat "$CALLBACK_FILE")" - # Extract access_token from URL params - MAGIC_LINK_TOKEN="$(echo "$CALLBACK_DATA" | grep -o 'access_token=[^&]*' | sed 's/access_token=//' || true)" -fi - -# ─── Step 4: Verify (OTP path or magic link path) ──────────── -if [ -n "$MAGIC_LINK_TOKEN" ]; then - # Magic link path — token already obtained - echo "" - echo "Magic link authenticated!" - - # Get user info from the token - USER_RESPONSE="$(curl -s \ - -H "Authorization: Bearer ${MAGIC_LINK_TOKEN}" \ - -H "apikey: ${ANON_KEY}" \ - "${AUTH_URL}/user" 2>/dev/null || echo "{}")" - - USER_ID="$(json_field "$USER_RESPONSE" "id")" - # Extract refresh_token from callback params - REFRESH_TOKEN="$(echo "$CALLBACK_DATA" | grep -o 'refresh_token=[^&]*' | sed 's/refresh_token=//' || true)" - EXPIRES_IN="$(echo "$CALLBACK_DATA" | grep -o 'expires_in=[^&]*' | sed 's/expires_in=//' || echo "3600")" - - save_token "$MAGIC_LINK_TOKEN" "$REFRESH_TOKEN" "$EXPIRES_IN" "$EMAIL" "$USER_ID" - -elif [ -n "$OTP_CODE" ]; then - # OTP path — verify the code - 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)" - - # Try to get user_id from nested user object if not at top level - 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" - -else - echo "" - echo "Timed out — no code entered and magic link not clicked." +if [ -z "$OTP_CODE" ]; then + echo "No code entered." exit 1 fi -# ─── Step 5: Save email to config ──────────────────────────── +# ─── 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 ""