big scary refactor. sync enable_bluesky_posts with captain record. implement oauth logout handler. implement crew assignment to hold. this caused a lot of circular dependencies and needed to move functions around in order to fix

This commit is contained in:
Evan Jarrett
2025-10-24 23:51:32 -05:00
parent 0c4d1cae8f
commit f75d9ceafb
33 changed files with 852 additions and 462 deletions
+20 -145
View File
@@ -2,17 +2,11 @@ package oauth
import (
"context"
"database/sql"
"fmt"
"html/template"
"net/http"
"strings"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// UISessionStore is the interface for UI session management
@@ -23,13 +17,18 @@ type UserStore interface {
UpsertUser(did, handle, pdsEndpoint, avatar string) error
}
// PostAuthCallback is called after successful OAuth authentication.
// Parameters: ctx, did, handle, pdsEndpoint, sessionID
// This allows AppView to perform business logic (profile creation, avatar fetch, etc.)
// without coupling the OAuth package to AppView-specific dependencies.
type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error
// Server handles OAuth authorization for the AppView
type Server struct {
app *App
refresher *Refresher
uiSessionStore UISessionStore
db *sql.DB
defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
app *App
refresher *Refresher
uiSessionStore UISessionStore
postAuthCallback PostAuthCallback
}
// NewServer creates a new OAuth server
@@ -39,13 +38,6 @@ func NewServer(app *App) *Server {
}
}
// SetDefaultHoldDID sets the default hold DID for profile creation
// Expected format: "did:web:hold01.atcr.io"
// To find a hold's DID, visit: https://hold-url/.well-known/did.json
func (s *Server) SetDefaultHoldDID(did string) {
s.defaultHoldDID = did
}
// SetRefresher sets the refresher for invalidating session cache
func (s *Server) SetRefresher(refresher *Refresher) {
s.refresher = refresher
@@ -56,9 +48,10 @@ func (s *Server) SetUISessionStore(store UISessionStore) {
s.uiSessionStore = store
}
// SetDatabase sets the database for user management
func (s *Server) SetDatabase(db *sql.DB) {
s.db = db
// SetPostAuthCallback sets the callback to be invoked after successful OAuth authentication
// This allows AppView to inject business logic without coupling the OAuth package
func (s *Server) SetPostAuthCallback(callback PostAuthCallback) {
s.postAuthCallback = callback
}
// ServeAuthorize handles GET /auth/oauth/authorize
@@ -140,9 +133,12 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
handle = did // Fallback to DID if resolution fails
}
// Fetch user's Bluesky profile (including avatar) and store in database
if s.db != nil {
s.fetchAndStoreAvatar(r.Context(), did, sessionID, handle, sessionData.HostURL)
// Call post-auth callback for AppView business logic (profile, avatar, etc.)
if s.postAuthCallback != nil {
if err := s.postAuthCallback(r.Context(), did, handle, sessionData.HostURL, sessionID); err != nil {
// Log error but don't fail OAuth flow - business logic is non-critical
fmt.Printf("WARNING [oauth/server]: Post-auth callback failed for DID=%s: %v\n", did, err)
}
}
// Check if this is a UI login (has oauth_return_to cookie)
@@ -241,127 +237,6 @@ func (s *Server) renderError(w http.ResponseWriter, message string) {
}
}
// fetchAndStoreAvatar fetches the user's Bluesky profile and stores avatar in database
func (s *Server) fetchAndStoreAvatar(ctx context.Context, did, sessionID, handle, pdsEndpoint string) {
fmt.Printf("DEBUG [oauth/server]: Fetching avatar for DID=%s from PDS=%s\n", did, pdsEndpoint)
// Parse DID for session resume
didParsed, err := syntax.ParseDID(did)
if err != nil {
fmt.Printf("WARNING [oauth/server]: Failed to parse DID %s: %v\n", did, err)
return
}
// Resume OAuth session to get authenticated client
session, err := s.app.ResumeSession(ctx, didParsed, sessionID)
if err != nil {
fmt.Printf("WARNING [oauth/server]: Failed to resume session for DID=%s: %v\n", did, err)
// Fallback: update user without avatar
_ = db.UpsertUser(s.db, &db.User{
DID: did,
Handle: handle,
PDSEndpoint: pdsEndpoint,
Avatar: "",
LastSeen: time.Now(),
})
return
}
// Create authenticated atproto client using the indigo session's API client
client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient())
// Ensure sailor profile exists (creates with default hold if configured, or empty profile if not)
fmt.Printf("DEBUG [oauth/server]: Ensuring profile exists for %s (defaultHold=%s)\n", did, s.defaultHoldDID)
if err := atproto.EnsureProfile(ctx, client, s.defaultHoldDID); err != nil {
fmt.Printf("WARNING [oauth/server]: Failed to ensure profile for %s: %v\n", did, err)
// Continue anyway - profile creation is not critical for avatar fetch
} else {
fmt.Printf("DEBUG [oauth/server]: Profile ensured for %s\n", did)
}
// Fetch user's profile record from PDS (contains blob references)
profileRecord, err := client.GetProfileRecord(ctx, did)
if err != nil {
fmt.Printf("WARNING [oauth/server]: Failed to fetch profile record for DID=%s: %v\n", did, err)
// Still update user without avatar
_ = db.UpsertUser(s.db, &db.User{
DID: did,
Handle: handle,
PDSEndpoint: pdsEndpoint,
Avatar: "",
LastSeen: time.Now(),
})
return
}
// Construct avatar URL from blob CID using imgs.blue CDN
var avatarURL string
if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" {
avatarURL = atproto.BlobCDNURL(did, profileRecord.Avatar.Ref.Link)
fmt.Printf("DEBUG [oauth/server]: Constructed avatar URL: %s\n", avatarURL)
}
// Store user with avatar in database
err = db.UpsertUser(s.db, &db.User{
DID: did,
Handle: handle,
PDSEndpoint: pdsEndpoint,
Avatar: avatarURL,
LastSeen: time.Now(),
})
if err != nil {
fmt.Printf("WARNING [oauth/server]: Failed to store user in database: %v\n", err)
return
}
fmt.Printf("DEBUG [oauth/server]: Stored user with avatar for DID=%s\n", did)
// Handle profile migration and crew registration
s.migrateProfileAndRegisterCrew(ctx, client, did, session)
}
// migrateProfileAndRegisterCrew handles URL→DID migration and crew registration
func (s *Server) migrateProfileAndRegisterCrew(ctx context.Context, client *atproto.Client, did string, session *indigooauth.ClientSession) {
// Get user's sailor profile
profile, err := atproto.GetProfile(ctx, client)
if err != nil {
fmt.Printf("WARNING [oauth/server]: Failed to get profile for %s: %v\n", did, err)
return
}
if profile == nil || profile.DefaultHold == "" {
// No profile or no default hold configured
return
}
// Check if defaultHold is a URL (needs migration)
var holdDID string
if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") {
fmt.Printf("DEBUG [oauth/server]: Migrating hold URL to DID for %s: %s\n", did, profile.DefaultHold)
// Resolve URL to DID
holdDID = atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
// Update profile with DID
profile.DefaultHold = holdDID
if err := atproto.UpdateProfile(ctx, client, profile); err != nil {
fmt.Printf("WARNING [oauth/server]: Failed to update profile with hold DID for %s: %v\n", did, err)
// Continue anyway - crew registration might still work
} else {
fmt.Printf("DEBUG [oauth/server]: Updated profile with hold DID: %s\n", holdDID)
}
} else {
// Already a DID
holdDID = profile.DefaultHold
}
// TODO: Request crew membership at the hold
// This requires understanding how to make authenticated HTTP requests with indigo's ClientSession
// For now, crew registration will happen on first push when appview validates access
fmt.Printf("DEBUG [oauth/server]: Skipping crew registration for now - will happen on first push. Hold DID: %s\n", holdDID)
_ = session // TODO: use session for crew registration
}
// HTML templates
const redirectToSettingsTemplate = `
+38 -31
View File
@@ -1,6 +1,7 @@
package token
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -11,30 +12,38 @@ import (
"github.com/bluesky-social/indigo/atproto/syntax"
"atcr.io/pkg/appview/db"
mainAtproto "atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
)
// PostAuthCallback is called after successful Basic Auth authentication.
// Parameters: ctx, did, handle, pdsEndpoint, accessToken
// This allows AppView to perform business logic (profile creation, etc.)
// without coupling the token package to AppView-specific dependencies.
type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error
// Handler handles /auth/token requests
type Handler struct {
issuer *Issuer
validator *auth.SessionValidator
deviceStore *db.DeviceStore // For validating device secrets
defaultHoldDID string
issuer *Issuer
validator *auth.SessionValidator
deviceStore *db.DeviceStore // For validating device secrets
postAuthCallback PostAuthCallback
}
// NewHandler creates a new token handler
// defaultHoldDID should be in format "did:web:hold01.atcr.io"
// To find a hold's DID, visit: https://hold-url/.well-known/did.json
func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore, defaultHoldDID string) *Handler {
func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore) *Handler {
return &Handler{
issuer: issuer,
validator: auth.NewSessionValidator(),
deviceStore: deviceStore,
defaultHoldDID: defaultHoldDID,
issuer: issuer,
validator: auth.NewSessionValidator(),
deviceStore: deviceStore,
}
}
// SetPostAuthCallback sets the callback to be invoked after successful Basic Auth authentication
// This allows AppView to inject business logic without coupling the token package
func (h *Handler) SetPostAuthCallback(callback PostAuthCallback) {
h.postAuthCallback = callback
}
// TokenResponse represents the response from /auth/token
type TokenResponse struct {
Token string `json:"token,omitempty"` // Legacy field
@@ -142,25 +151,23 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour)
fmt.Printf("DEBUG [token/handler]: Cached access token for DID=%s\n", did)
// Ensure user profile exists (creates with default hold if needed)
// Resolve PDS endpoint for profile management
directory := identity.DefaultDirectory()
atID, err := syntax.ParseAtIdentifier(username)
if err == nil {
ident, err := directory.Lookup(r.Context(), *atID)
if err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err)
} else {
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint != "" {
// Create ATProto client with validated token
atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken)
// Ensure profile exists (will create with default hold if not exists and default is configured)
if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldDID); err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
// Call post-auth callback for AppView business logic (profile management, etc.)
if h.postAuthCallback != nil {
// Resolve PDS endpoint for callback
directory := identity.DefaultDirectory()
atID, err := syntax.ParseAtIdentifier(username)
if err == nil {
ident, err := directory.Lookup(r.Context(), *atID)
if err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING: failed to resolve PDS for callback: %v\n", err)
} else {
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint != "" {
if err := h.postAuthCallback(r.Context(), did, handle, pdsEndpoint, accessToken); err != nil {
// Log error but don't fail auth - business logic is non-critical
fmt.Printf("WARNING: post-auth callback failed for DID=%s: %v\n", did, err)
}
}
}
}
+111
View File
@@ -0,0 +1,111 @@
package token
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
)
// GetOrFetchServiceToken gets a service token for hold authentication.
// Checks cache first, then fetches from PDS with OAuth/DPoP if needed.
// This is the canonical implementation used by both middleware and crew registration.
func GetOrFetchServiceToken(
ctx context.Context,
refresher *oauth.Refresher,
did, holdDID, pdsEndpoint string,
) (string, error) {
if refresher == nil {
return "", fmt.Errorf("refresher is nil (OAuth session required for service tokens)")
}
// Check cache first to avoid unnecessary PDS calls on every request
cachedToken, expiresAt := GetServiceToken(did, holdDID)
// Use cached token if it exists and has > 10s remaining
if cachedToken != "" && time.Until(expiresAt) > 10*time.Second {
fmt.Printf("DEBUG [atproto/servicetoken]: Using cached service token for DID=%s (expires in %v)\n",
did, time.Until(expiresAt).Round(time.Second))
return cachedToken, nil
}
// Cache miss or expiring soon - validate OAuth and get new service token
if cachedToken == "" {
fmt.Printf("DEBUG [atproto/servicetoken]: Cache miss, fetching service token for DID=%s\n", did)
} else {
fmt.Printf("DEBUG [atproto/servicetoken]: Token expiring soon, proactively renewing for DID=%s\n", did)
}
session, err := refresher.GetSession(ctx, did)
if err != nil {
// OAuth session unavailable - invalidate and fail
refresher.InvalidateSession(did)
InvalidateServiceToken(did, holdDID)
return "", fmt.Errorf("failed to get OAuth session: %w", err)
}
// Call com.atproto.server.getServiceAuth on the user's PDS
// Request 5-minute expiry (PDS may grant less)
// exp must be absolute Unix timestamp, not relative duration
// Note: OAuth scope includes #atcr_hold fragment, but service auth aud must be bare DID
expiryTime := time.Now().Unix() + 300 // 5 minutes from now
serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d",
pdsEndpoint,
atproto.ServerGetServiceAuth,
url.QueryEscape(holdDID),
url.QueryEscape("com.atproto.repo.getRecord"),
expiryTime,
)
req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create service auth request: %w", err)
}
// Use OAuth session to authenticate to PDS (with DPoP)
resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth")
if err != nil {
// Invalidate session on auth errors (may indicate corrupted session or expired tokens)
refresher.InvalidateSession(did)
InvalidateServiceToken(did, holdDID)
return "", fmt.Errorf("OAuth validation failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Invalidate session on auth failures
bodyBytes, _ := io.ReadAll(resp.Body)
refresher.InvalidateSession(did)
InvalidateServiceToken(did, holdDID)
return "", fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
// Parse response to get service token
var result struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode service auth response: %w", err)
}
if result.Token == "" {
return "", fmt.Errorf("empty token in service auth response")
}
serviceToken := result.Token
// Cache the token (parses JWT to extract actual expiry)
if err := SetServiceToken(did, holdDID, serviceToken); err != nil {
fmt.Printf("WARN [atproto/servicetoken]: Failed to cache service token: %v\n", err)
// Non-fatal - we have the token, just won't be cached
}
fmt.Printf("DEBUG [atproto/servicetoken]: OAuth validation succeeded for DID=%s\n", did)
return serviceToken, nil
}