mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-01 07:46:55 +00:00
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:
+20
-145
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user