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
+22 -96
View File
@@ -4,12 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
@@ -73,6 +69,7 @@ type NamespaceResolver struct {
distribution.Namespace
directory identity.Directory
defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
baseURL string // Base URL for error messages (e.g., "https://atcr.io")
testMode bool // If true, fallback to default hold when user's hold is unreachable
repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
refresher *oauth.Refresher // OAuth session manager (copied from global on init)
@@ -93,6 +90,12 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive
defaultHoldDID = holdDID
}
// Get base URL from config (for error messages)
baseURL := ""
if url, ok := options["base_url"].(string); ok {
baseURL = url
}
// Check test mode from options (passed via env var)
testMode := false
if tm, ok := options["test_mode"].(bool); ok {
@@ -105,6 +108,7 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive
Namespace: ns,
directory: directory,
defaultHoldDID: defaultHoldDID,
baseURL: baseURL,
testMode: testMode,
refresher: globalRefresher,
database: globalDatabase,
@@ -113,6 +117,13 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive
}, nil
}
// authErrorMessage creates a user-friendly auth error with login URL
func (nr *NamespaceResolver) authErrorMessage(message string) error {
loginURL := fmt.Sprintf("%s/auth/oauth/login", nr.baseURL)
fullMessage := fmt.Sprintf("%s - please re-authenticate at %s", message, loginURL)
return errcode.ErrorCodeUnauthorized.WithMessage(fullMessage)
}
// Repository resolves the repository name and delegates to underlying namespace
// Handles names like:
// - atcr.io/alice/myimage → resolve alice to DID
@@ -160,99 +171,14 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
ctx = context.WithValue(ctx, holdDIDKey, holdDID)
// Get service token for hold authentication
// Check cache first to avoid unnecessary PDS calls on every request
var serviceToken string
if nr.refresher != nil {
cachedToken, expiresAt := token.GetServiceToken(did, holdDID)
// Use cached token if it exists and has > 10s remaining
if cachedToken != "" && time.Until(expiresAt) > 10*time.Second {
fmt.Printf("DEBUG [registry/middleware]: Using cached service token for DID=%s (expires in %v)\n",
did, time.Until(expiresAt).Round(time.Second))
serviceToken = cachedToken
} else {
// Cache miss or expiring soon - validate OAuth and get new service token
if cachedToken == "" {
fmt.Printf("DEBUG [registry/middleware]: Cache miss, fetching service token for DID=%s\n", did)
} else {
fmt.Printf("DEBUG [registry/middleware]: Token expiring soon, proactively renewing for DID=%s\n", did)
}
session, err := nr.refresher.GetSession(ctx, did)
if err != nil {
// OAuth session unavailable - fail fast with proper auth error
nr.refresher.InvalidateSession(did)
token.InvalidateServiceToken(did, holdDID)
fmt.Printf("ERROR [registry/middleware]: Failed to get OAuth session for DID=%s: %v\n", did, err)
fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate")
}
// 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 {
fmt.Printf("ERROR [registry/middleware]: Failed to create service auth request: %v\n", err)
return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed")
}
// 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)
nr.refresher.InvalidateSession(did)
token.InvalidateServiceToken(did, holdDID)
fmt.Printf("ERROR [registry/middleware]: OAuth validation failed for DID=%s: %v\n", did, err)
fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Invalidate session on auth failures
bodyBytes, _ := io.ReadAll(resp.Body)
nr.refresher.InvalidateSession(did)
token.InvalidateServiceToken(did, holdDID)
fmt.Printf("ERROR [registry/middleware]: OAuth validation failed for DID=%s: status %d, body: %s\n",
did, resp.StatusCode, string(bodyBytes))
fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate")
}
// Parse response to get service token
var result struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
fmt.Printf("ERROR [registry/middleware]: Failed to decode service auth response: %v\n", err)
return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed")
}
if result.Token == "" {
fmt.Printf("ERROR [registry/middleware]: Empty token in service auth response\n")
return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed")
}
serviceToken = result.Token
// Cache the token (parses JWT to extract actual expiry)
if err := token.SetServiceToken(did, holdDID, serviceToken); err != nil {
fmt.Printf("WARN [registry/middleware]: Failed to cache service token: %v\n", err)
// Non-fatal - we have the token, just won't be cached
}
fmt.Printf("DEBUG [registry/middleware]: OAuth validation succeeded for DID=%s\n", did)
var err error
serviceToken, err = token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint)
if err != nil {
fmt.Printf("ERROR [registry/middleware]: Failed to get service token for DID=%s: %v\n", did, err)
fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
return nil, nr.authErrorMessage("OAuth session expired")
}
}
@@ -366,7 +292,7 @@ func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint s
client := atproto.NewClient(pdsEndpoint, did, "")
// Check for sailor profile
profile, err := atproto.GetProfile(ctx, client)
profile, err := storage.GetProfile(ctx, client)
if err != nil {
// Error reading profile (not a 404) - log and continue
fmt.Printf("WARNING: failed to read profile for %s: %v\n", did, err)