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)
}