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