#!/usr/bin/env bash # gstack-auth — authenticate with Supabase via email OTP + magic link # # 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. # # 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" </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 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 # ─── 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 # 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) ──────────────── echo "" echo "Sending verification email 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" \ -H "Content-Type: application/json" \ -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)" case "$HTTP_CODE" in 2*) ;; # success 429) echo "Rate limited — please wait 60 seconds and try again." exit 1 ;; *) echo "Error sending OTP (HTTP ${HTTP_CODE}): ${HTTP_BODY}" exit 1 ;; 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 "" # ─── 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 ─────────── printf "Enter 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." exit 1 fi # ─── Step 5: Save email to config ──────────────────────────── "$GSTACK_DIR/bin/gstack-config" set email "$EMAIL" echo "" echo "Authenticated as: ${EMAIL}" echo "Token saved to: ${AUTH_FILE}"