Connor McCutcheon
/ Skykit
authentication.go
go
package skykit
import (
	"cmp"
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"
)
// User represents a Skyscape user authenticated via OAuth
type User struct {
	Model
	Handle      string
	Name        string
	Email       string
	Avatar      string
	AccessToken string
	ExpiresAt   time.Time
}
// Authentication provides OAuth integration with The Skyscape platform
type Authentication struct {
	*Collection[*User]
	skyscapeHost string
	clientID     string
	clientSecret string
	redirectURI  string
	cookieName   string
}
// NewAuthentication creates and configures the authentication system
func NewAuthentication(db *Database) *Authentication {
	// Get app ID from environment
	clientID := cmp.Or(os.Getenv("APP_ID"), strings.TrimSuffix(os.Getenv("DB_NAME"), "-data.db"))
	auth := &Authentication{
		Collection:   Manage(db, "users", new(User)),
		skyscapeHost: cmp.Or(os.Getenv("SKYSCAPE_HOST"), "https://theskyscape.com"),
		clientID:     clientID,
		clientSecret: os.Getenv("OAUTH_CLIENT_SECRET"),
		redirectURI:  fmt.Sprintf("https://%s.skysca.pe/auth/callback", clientID),
		cookieName:   clientID,
	}
	// Register authentication routes
	http.HandleFunc("GET /auth/signin", auth.signin)
	http.HandleFunc("GET /auth/callback", auth.callback)
	http.HandleFunc("GET /auth/logout", auth.logout)
	return auth
}
// Authenticate returns the currently authenticated user from the request
func (a *Authentication) Authenticate(r *http.Request) (*User, error) {
	cookie, err := r.Cookie(a.cookieName)
	if err != nil {
		return nil, err
	}
	return a.Get(cookie.Value)
}
// signin redirects to The Skyscape OAuth authorization
func (a *Authentication) signin(w http.ResponseWriter, r *http.Request) {
	// Generate state for CSRF protection
	stateBytes := make([]byte, 32)
	rand.Read(stateBytes)
	state := base64.URLEncoding.EncodeToString(stateBytes)
	// Build authorization URL
	params := url.Values{
		"client_id":     {a.clientID},
		"redirect_uri":  {a.redirectURI},
		"response_type": {"code"},
		"scope":         {"user:read"},
		"state":         {state},
	}
	authURL := fmt.Sprintf("%s/oauth/authorize?%s", a.skyscapeHost, params.Encode())
	// Store state and redirect URL in cookies
	http.SetCookie(w, &http.Cookie{
		Name: "oauth_state", Value: state, Path: "/", HttpOnly: true,
		Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 600,
	})
	// Don't redirect back to signin page after auth - use home instead
	redirectAfterAuth := r.URL.String()
	if redirectAfterAuth == "/auth/signin" || redirectAfterAuth == "/auth/callback" {
		redirectAfterAuth = "/"
	}
	http.SetCookie(w, &http.Cookie{
		Name: "auth_redirect", Value: redirectAfterAuth, Path: "/", HttpOnly: true,
		Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 600,
	})
	http.Redirect(w, r, authURL, http.StatusSeeOther)
}
// callback handles the OAuth callback from The Skyscape
func (a *Authentication) callback(w http.ResponseWriter, r *http.Request) {
	// Verify state
	stateCookie, err := r.Cookie("oauth_state")
	if err != nil || r.URL.Query().Get("state") != stateCookie.Value {
		http.Error(w, "Invalid state", http.StatusBadRequest)
		return
	}
	http.SetCookie(w, &http.Cookie{
		Name: "oauth_state", Value: "", Path: "/", HttpOnly: true, MaxAge: -1,
	})
	// Exchange code for token
	code := r.URL.Query().Get("code")
	if code == "" {
		http.Error(w, "Missing code", http.StatusBadRequest)
		return
	}
	data := url.Values{
		"grant_type":   {"authorization_code"},
		"code":         {code},
		"redirect_uri": {a.redirectURI},
	}
	req, _ := http.NewRequest("POST", a.skyscapeHost+"/oauth/token", strings.NewReader(data.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.SetBasicAuth(a.clientID, a.clientSecret)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		http.Error(w, "Token exchange failed", http.StatusInternalServerError)
		return
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		http.Error(w, fmt.Sprintf("Token exchange failed: %s", body), http.StatusInternalServerError)
		return
	}
	var tokenResp struct {
		AccessToken string `json:"access_token"`
		ExpiresIn   int    `json:"expires_in"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
		http.Error(w, fmt.Sprintf("Failed to parse token response: %v", err), http.StatusInternalServerError)
		return
	}
	if tokenResp.AccessToken == "" {
		http.Error(w, "Token response missing access_token", http.StatusInternalServerError)
		return
	}
	// Get user data
	req, _ = http.NewRequest("GET", a.skyscapeHost+"/api/user", nil)
	req.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
	resp, err = http.DefaultClient.Do(req)
	if err != nil {
		http.Error(w, fmt.Sprintf("Failed to get user: %v", err), http.StatusInternalServerError)
		return
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		http.Error(w, fmt.Sprintf("Failed to get user (status %d): %s", resp.StatusCode, string(body)), http.StatusInternalServerError)
		return
	}
	var apiUser struct {
		ID     string `json:"id"`
		Handle string `json:"handle"`
		Name   string `json:"name"`
		Email  string `json:"email"`
		Avatar string `json:"avatar"`
	}
	json.NewDecoder(resp.Body).Decode(&apiUser)
	// Store or update user - use ID from API as stable identifier
	expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
	// Try to find existing user by ID first (stable), then by Handle (for migration)
	user, err := a.Get(apiUser.ID)
	if err != nil || user == nil {
		user, err = a.First("WHERE Handle = ?", apiUser.Handle)
	}
	if err == nil && user != nil {
		// Always sync profile data on login
		user.Handle = apiUser.Handle
		user.Name = apiUser.Name
		user.Email = apiUser.Email
		user.Avatar = apiUser.Avatar
		user.AccessToken = tokenResp.AccessToken
		user.ExpiresAt = expiresAt
		a.Update(user)
	} else {
		// Create new user with ID from API
		user = &User{
			Handle:      apiUser.Handle,
			Name:        apiUser.Name,
			Email:       apiUser.Email,
			Avatar:      apiUser.Avatar,
			AccessToken: tokenResp.AccessToken,
			ExpiresAt:   expiresAt,
		}
		user.ID = apiUser.ID // Use the ID from the API
		a.Insert(user)
	}
	// Set session cookie
	http.SetCookie(w, &http.Cookie{
		Name:     a.cookieName,
		Value:    user.ID,
		Path:     "/",
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   30 * 24 * 60 * 60,
	})
	// Redirect to original URL or home
	redirectURL := "/"
	if redirectCookie, err := r.Cookie("auth_redirect"); err == nil {
		redirectURL = redirectCookie.Value
		http.SetCookie(w, &http.Cookie{
			Name: "auth_redirect", Value: "", Path: "/", HttpOnly: true, MaxAge: -1,
		})
	}
	http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}
// logout clears the user session
func (a *Authentication) logout(w http.ResponseWriter, r *http.Request) {
	http.SetCookie(w, &http.Cookie{
		Name: a.cookieName, Value: "", Path: "/", HttpOnly: true, MaxAge: -1,
	})
	http.Redirect(w, r, "/", http.StatusSeeOther)
}
No comments yet.