diff --git a/cmd/profile-update/main.go b/cmd/profile-update/main.go deleted file mode 100644 index 630b453..0000000 --- a/cmd/profile-update/main.go +++ /dev/null @@ -1,122 +0,0 @@ -package main - -import ( - "context" - "encoding/base64" - "encoding/json" - "flag" - "fmt" - "log" - "os" - "path/filepath" - "strings" - - "atcr.io/pkg/atproto" - atprotoAuth "atcr.io/pkg/auth/atproto" -) - -// DockerConfig represents ~/.docker/config.json -type DockerConfig struct { - Auths map[string]AuthEntry `json:"auths"` -} - -type AuthEntry struct { - Auth string `json:"auth"` // base64(username:password) -} - -func main() { - var defaultHold string - var registryURL string - - flag.StringVar(&defaultHold, "default-hold", "", "Default hold endpoint URL (e.g., http://172.28.0.3:8080)") - flag.StringVar(®istryURL, "registry", "127.0.0.1:5000", "Registry URL to read auth from Docker config") - flag.Parse() - - // Read Docker config - home, err := os.UserHomeDir() - if err != nil { - log.Fatalf("Failed to get home directory: %v", err) - } - dockerConfigPath := filepath.Join(home, ".docker", "config.json") - - configData, err := os.ReadFile(dockerConfigPath) - if err != nil { - log.Fatalf("Failed to read Docker config: %v\n\nMake sure you've logged in with: docker login %s", err, registryURL) - } - - var dockerConfig DockerConfig - if err := json.Unmarshal(configData, &dockerConfig); err != nil { - log.Fatalf("Failed to parse Docker config: %v", err) - } - - // Get auth for registry - authEntry, ok := dockerConfig.Auths[registryURL] - if !ok { - log.Fatalf("No auth found for registry %s in Docker config", registryURL) - } - - // Decode base64 auth (format: "username:password") - authBytes, err := base64.StdEncoding.DecodeString(authEntry.Auth) - if err != nil { - log.Fatalf("Failed to decode auth: %v", err) - } - - parts := strings.SplitN(string(authBytes), ":", 2) - if len(parts) != 2 { - log.Fatalf("Invalid auth format") - } - - handle := parts[0] - password := parts[1] // This should be an app password - - fmt.Printf("Handle: %s\n", handle) - - // Create session validator and get access token - validator := atprotoAuth.NewSessionValidator() - ctx := context.Background() - - did, pdsEndpoint, accessToken, err := validator.CreateSessionAndGetToken(ctx, handle, password) - if err != nil { - log.Fatalf("Failed to authenticate: %v", err) - } - - fmt.Printf("DID: %s\n", did) - fmt.Printf("PDS: %s\n\n", pdsEndpoint) - - // Create client with the access token from createSession - client := atproto.NewClient(pdsEndpoint, did, accessToken) - - // Get current profile - profile, err := atproto.GetProfile(ctx, client) - if err != nil { - log.Fatalf("Failed to get current profile: %v", err) - } - - if profile == nil { - if defaultHold == "" { - fmt.Println("No existing profile found.") - fmt.Println("\nTo create profile with default hold, use: -default-hold ") - return - } - fmt.Println("No existing profile found. Creating new profile...") - profile = atproto.NewSailorProfileRecord(defaultHold) - } else { - fmt.Printf("Current defaultHold: %s\n", profile.DefaultHold) - if defaultHold == "" { - // Just show current profile - fmt.Println("\nTo update, use: -default-hold ") - return - } - profile.DefaultHold = defaultHold - } - - // Update profile - if defaultHold != "" { - err = atproto.UpdateProfile(ctx, client, profile) - if err != nil { - log.Fatalf("Failed to update profile: %v", err) - } - - fmt.Printf("\n✓ Updated defaultHold to: %s\n", defaultHold) - } -} diff --git a/pkg/auth/atproto/session.go b/pkg/auth/atproto/session.go index 9873a7f..40feedc 100644 --- a/pkg/auth/atproto/session.go +++ b/pkg/auth/atproto/session.go @@ -19,6 +19,7 @@ import ( // CachedSession represents a cached session type CachedSession struct { DID string + Handle string PDS string AccessToken string ExpiresAt time.Time @@ -83,49 +84,13 @@ type SessionResponse struct { AccessToken string `json:"access_token,omitempty"` // Alternative field name } -// ValidateCredentials validates username and password against ATProto -// Returns the user's DID and PDS endpoint if valid -func (v *SessionValidator) ValidateCredentials(ctx context.Context, identifier, password string) (did, pdsEndpoint string, err error) { - // Resolve identifier (handle or DID) to PDS endpoint - atID, err := syntax.ParseAtIdentifier(identifier) - if err != nil { - return "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err) - } - - ident, err := v.directory.Lookup(ctx, *atID) - if err != nil { - return "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err) - } - - resolvedDID := ident.DID.String() - pds := ident.PDSEndpoint() - if pds == "" { - return "", "", fmt.Errorf("no PDS endpoint found for %q", identifier) - } - - fmt.Printf("DEBUG: Resolved %s to DID=%s, PDS=%s\n", identifier, resolvedDID, pds) - - // Create session with the PDS - fmt.Printf("DEBUG [atproto/session]: Creating session for %s at PDS %s\n", identifier, pds) - sessionResp, err := v.createSession(ctx, pds, identifier, password) - if err != nil { - fmt.Printf("DEBUG [atproto/session]: Session creation failed: %v\n", err) - return "", "", fmt.Errorf("authentication failed for %s at PDS %s: %w", identifier, pds, err) - } - - fmt.Printf("DEBUG [atproto/session]: Session created successfully, DID=%s, Handle=%s, AccessJWT length=%d\n", - sessionResp.DID, sessionResp.Handle, len(sessionResp.AccessJWT)) - - return sessionResp.DID, pds, nil -} - -// CreateSessionAndGetToken creates a session and returns the DID, PDS endpoint, and access token -func (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identifier, password string) (did, pdsEndpoint, accessToken string, err error) { +// CreateSessionAndGetToken creates a session and returns the DID, handle, and access token +func (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identifier, password string) (did, handle, accessToken string, err error) { // Check cache first cacheKey := getCacheKey(identifier, password) if cached, ok := v.getCachedSession(cacheKey); ok { fmt.Printf("DEBUG [atproto/session]: Using cached session for %s (DID=%s)\n", identifier, cached.DID) - return cached.DID, cached.PDS, cached.AccessToken, nil + return cached.DID, cached.Handle, cached.AccessToken, nil } fmt.Printf("DEBUG [atproto/session]: No cached session for %s, creating new session\n", identifier) @@ -156,13 +121,14 @@ func (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identif // Cache the session (ATProto sessions typically last 2 hours) v.setCachedSession(cacheKey, &CachedSession{ DID: sessionResp.DID, + Handle: sessionResp.Handle, PDS: pds, AccessToken: sessionResp.AccessJWT, ExpiresAt: time.Now().Add(2 * time.Hour), }) fmt.Printf("DEBUG [atproto/session]: Cached session for %s (expires in 2 hours)\n", identifier) - return sessionResp.DID, pds, sessionResp.AccessJWT, nil + return sessionResp.DID, sessionResp.Handle, sessionResp.AccessJWT, nil } // createSession calls com.atproto.server.createSession diff --git a/pkg/auth/atproto/validator.go b/pkg/auth/atproto/validator.go deleted file mode 100644 index 1a1147e..0000000 --- a/pkg/auth/atproto/validator.go +++ /dev/null @@ -1,112 +0,0 @@ -package atproto - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/bluesky-social/indigo/atproto/identity" - "github.com/bluesky-social/indigo/atproto/syntax" -) - -// TokenValidator validates ATProto OAuth access tokens -type TokenValidator struct { - httpClient *http.Client -} - -// NewTokenValidator creates a new token validator -func NewTokenValidator() *TokenValidator { - return &TokenValidator{ - httpClient: &http.Client{}, - } -} - -// SessionInfo represents the response from com.atproto.server.getSession -type SessionInfo struct { - DID string `json:"did"` - Handle string `json:"handle"` - Email string `json:"email,omitempty"` - EmailConfirmed bool `json:"emailConfirmed,omitempty"` - Active bool `json:"active,omitempty"` -} - -// ValidateToken validates an ATProto OAuth access token by calling getSession -// Returns the user's DID and handle if the token is valid -// dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer -func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken, dpopProof string) (*SessionInfo, error) { - // Call com.atproto.server.getSession with the access token - url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", pdsEndpoint) - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Always use Bearer auth for getSession validation - // The DPoP proof from the client is bound to their request to us (POST /auth/exchange), - // not to our request to the PDS (GET /getSession) - req.Header.Set("Authorization", "Bearer "+accessToken) - - fmt.Printf("DEBUG [validator]: calling %s with Bearer auth, token_prefix=%s...\n", - url, accessToken[:min(20, len(accessToken))]) - - resp, err := v.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get session: %w", err) - } - defer resp.Body.Close() - - // Read body once for both logging and error handling - bodyBytes, _ := io.ReadAll(resp.Body) - - if resp.StatusCode == http.StatusUnauthorized { - fmt.Printf("DEBUG [validator]: getSession returned 401: %s\n", string(bodyBytes)) - return nil, fmt.Errorf("invalid or expired token") - } - - if resp.StatusCode != http.StatusOK { - fmt.Printf("DEBUG [validator]: getSession failed with status %d: %s\n", resp.StatusCode, string(bodyBytes)) - return nil, fmt.Errorf("getSession failed with status %d: %s", resp.StatusCode, string(bodyBytes)) - } - - var session SessionInfo - if err := json.Unmarshal(bodyBytes, &session); err != nil { - return nil, fmt.Errorf("failed to decode session: %w", err) - } - - // Validate required fields - if session.DID == "" { - return nil, fmt.Errorf("session response missing DID") - } - if session.Handle == "" { - return nil, fmt.Errorf("session response missing handle") - } - - return &session, nil -} - -// ValidateTokenWithResolver validates a token and automatically resolves the PDS endpoint -// dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer -func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken, dpopProof string) (*SessionInfo, error) { - // Resolve handle to PDS endpoint - directory := identity.DefaultDirectory() - atID, err := syntax.ParseAtIdentifier(handle) - if err != nil { - return nil, fmt.Errorf("invalid identifier %q: %w", handle, err) - } - - ident, err := directory.Lookup(ctx, *atID) - if err != nil { - return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err) - } - - pdsEndpoint := ident.PDSEndpoint() - if pdsEndpoint == "" { - return nil, fmt.Errorf("no PDS endpoint found for %q", handle) - } - - // Validate token against the PDS - return v.ValidateToken(ctx, pdsEndpoint, accessToken, dpopProof) -} diff --git a/pkg/auth/oauth/interactive.go b/pkg/auth/oauth/interactive.go index 36bd1d5..767f4e4 100644 --- a/pkg/auth/oauth/interactive.go +++ b/pkg/auth/oauth/interactive.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "net/http" - "net/url" - "sync" "time" "github.com/bluesky-social/indigo/atproto/auth/oauth" @@ -18,91 +16,6 @@ type InteractiveResult struct { App *App } -// RunInteractiveFlow runs an interactive OAuth flow for CLI tools -// This is a simplified wrapper around indigo's OAuth flow -func RunInteractiveFlow( - ctx context.Context, - baseURL string, - handle string, - scopes []string, - onAuthURL func(string) error, -) (*InteractiveResult, error) { - // Create temporary file store for this flow - store, err := NewFileStore("/tmp/atcr-oauth-temp.json") - if err != nil { - return nil, fmt.Errorf("failed to create OAuth store: %w", err) - } - - // Create OAuth app - app, err := NewApp(baseURL, store) - if err != nil { - return nil, fmt.Errorf("failed to create OAuth app: %w", err) - } - - // Set custom scopes if provided - if len(scopes) > 0 { - // Note: indigo's ClientApp doesn't expose SetScopes, so we need to use default scopes - // This is a limitation of the current implementation - // TODO: Enhance if custom scopes are needed - } - - // Start auth flow - authURL, err := app.StartAuthFlow(ctx, handle) - if err != nil { - return nil, fmt.Errorf("failed to start auth flow: %w", err) - } - - // Call the callback to display the auth URL - if err := onAuthURL(authURL); err != nil { - return nil, fmt.Errorf("auth URL callback failed: %w", err) - } - - // Wait for OAuth callback - // The callback will be handled by the http.HandleFunc registered by the caller - // We need to wait for ProcessCallback to be called - // This is a bit awkward, but matches the old pattern - - // Setup a channel to receive callback params - callbackChan := make(chan url.Values, 1) - var setupOnce sync.Once - - // Return a function that the caller can use to process the callback - // This is called from the HTTP handler - processCallback := func(params url.Values) (*oauth.ClientSessionData, error) { - setupOnce.Do(func() { - callbackChan <- params - }) - sessionData, err := app.ProcessCallback(ctx, params) - if err != nil { - return nil, fmt.Errorf("failed to process callback: %w", err) - } - return sessionData, nil - } - - // Wait for callback with timeout - select { - case params := <-callbackChan: - sessionData, err := processCallback(params) - if err != nil { - return nil, err - } - - // Resume session to get ClientSession - session, err := app.ResumeSession(ctx, sessionData.AccountDID, sessionData.SessionID) - if err != nil { - return nil, fmt.Errorf("failed to resume session: %w", err) - } - - return &InteractiveResult{ - SessionData: sessionData, - Session: session, - App: app, - }, nil - case <-time.After(5 * time.Minute): - return nil, fmt.Errorf("OAuth flow timed out after 5 minutes") - } -} - // InteractiveFlowWithCallback runs an interactive OAuth flow with explicit callback handling // This version allows the caller to register the callback handler before starting the flow func InteractiveFlowWithCallback( diff --git a/pkg/auth/oauth/refresher.go b/pkg/auth/oauth/refresher.go index 483fc1b..f1dbf31 100644 --- a/pkg/auth/oauth/refresher.go +++ b/pkg/auth/oauth/refresher.go @@ -3,7 +3,6 @@ package oauth import ( "context" "fmt" - "net/http" "sync" "github.com/bluesky-social/indigo/atproto/auth/oauth" @@ -74,33 +73,6 @@ func (r *Refresher) GetSession(ctx context.Context, did string) (*oauth.ClientSe return r.resumeSession(ctx, did) } -// GetAccessToken gets a fresh access token for a DID -// This is a convenience method that extracts the access token from the session -func (r *Refresher) GetAccessToken(ctx context.Context, did string) (string, error) { - session, err := r.GetSession(ctx, did) - if err != nil { - return "", err - } - - // Get access token and DPoP nonce from session - accessToken, _ := session.GetHostAccessData() - return accessToken, nil -} - -// GetHTTPClient returns an HTTP client with DPoP authentication for a DID -// The client automatically adds DPoP headers and refreshes tokens as needed -func (r *Refresher) GetHTTPClient(ctx context.Context, did string) (*http.Client, error) { - session, err := r.GetSession(ctx, did) - if err != nil { - return nil, err - } - - // Get API client from session - // This client automatically handles DPoP and token refresh - apiClient := session.APIClient() - return apiClient.Client, nil -} - // resumeSession loads a session from storage and caches it func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.ClientSession, error) { // Parse DID @@ -153,72 +125,3 @@ func (r *Refresher) InvalidateSession(did string) { delete(r.sessions, did) r.mu.Unlock() } - -// RevokeSession removes a session from both cache and storage -func (r *Refresher) RevokeSession(ctx context.Context, did string) error { - // Remove from cache - r.mu.Lock() - cached, ok := r.sessions[did] - delete(r.sessions, did) - r.mu.Unlock() - - if !ok { - // Not cached, still try to delete from storage - accountDID, err := syntax.ParseDID(did) - if err != nil { - return fmt.Errorf("failed to parse DID: %w", err) - } - - // Find session ID from store - fileStore, ok := r.app.clientApp.Store.(*FileStore) - if !ok { - return fmt.Errorf("store is not a FileStore") - } - - sessions := fileStore.ListSessions() - for _, sessionData := range sessions { - if sessionData.AccountDID.String() == did { - return r.app.clientApp.Store.DeleteSession(ctx, accountDID, sessionData.SessionID) - } - } - - return fmt.Errorf("no session found for DID: %s", did) - } - - // Revoke the session via OAuth - if err := cached.Session.RevokeSession(ctx); err != nil { - fmt.Printf("WARNING: failed to revoke session for %s: %v\n", did, err) - // Continue anyway to delete from storage - } - - // Delete from storage - accountDID, err := syntax.ParseDID(did) - if err != nil { - return fmt.Errorf("failed to parse DID: %w", err) - } - - return r.app.clientApp.Store.DeleteSession(ctx, accountDID, cached.SessionID) -} - -// CleanupExpiredSessions removes expired sessions from cache -// Note: indigo handles token expiry automatically, but we clean up orphaned cache entries -func (r *Refresher) CleanupExpiredSessions(ctx context.Context) { - r.mu.Lock() - defer r.mu.Unlock() - - // For each cached session, verify it still exists in storage - for did, cached := range r.sessions { - accountDID, err := syntax.ParseDID(did) - if err != nil { - delete(r.sessions, did) - continue - } - - // Try to get session from store - _, err = r.app.clientApp.Store.GetSession(ctx, accountDID, cached.SessionID) - if err != nil { - // Session no longer exists, remove from cache - delete(r.sessions, did) - } - } -} diff --git a/pkg/auth/oauth/server.go b/pkg/auth/oauth/server.go index 1fb1007..81db705 100644 --- a/pkg/auth/oauth/server.go +++ b/pkg/auth/oauth/server.go @@ -1,13 +1,10 @@ package oauth import ( - "context" "fmt" "html/template" "net/http" "time" - - "github.com/bluesky-social/indigo/atproto/syntax" ) // UISessionStore is the interface for UI session management @@ -102,9 +99,9 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) { fmt.Printf("DEBUG [oauth/server]: Invalidated cached session for DID=%s after creating new session\n", did) } - // We need to get the handle for UI sessions and settings redirect - // Resolve DID to handle using our resolver - handle, err := s.resolveHandle(r.Context(), did) + // Look up identity + ident, err := s.app.directory.LookupDID(r.Context(), sessionData.AccountDID) + handle := ident.Handle.String() if err != nil { fmt.Printf("WARNING [oauth/server]: Failed to resolve DID to handle: %v, using DID as handle\n", err) handle = did // Fallback to DID if resolution fails @@ -152,25 +149,6 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) { s.renderRedirectToSettings(w, handle) } -// resolveHandle attempts to resolve a DID to a handle -// This is a best-effort helper - we use the directory to look up the handle -func (s *Server) resolveHandle(ctx context.Context, didStr string) (string, error) { - // Parse DID - did, err := syntax.ParseDID(didStr) - if err != nil { - return "", fmt.Errorf("invalid DID: %w", err) - } - - // Look up identity - ident, err := s.app.directory.LookupDID(ctx, did) - if err != nil { - return "", fmt.Errorf("failed to lookup DID: %w", err) - } - - // Return handle (may be handle.invalid if verification failed) - return ident.Handle.String(), nil -} - // renderRedirectToSettings redirects to the settings page to generate an API key func (s *Server) renderRedirectToSettings(w http.ResponseWriter, handle string) { tmpl := template.Must(template.New("redirect").Parse(redirectToSettingsTemplate)) diff --git a/pkg/server/handler.go b/pkg/server/handler.go deleted file mode 100644 index 38b247e..0000000 --- a/pkg/server/handler.go +++ /dev/null @@ -1,57 +0,0 @@ -package server - -import ( - "net/http" - "strings" - - "github.com/bluesky-social/indigo/atproto/identity" -) - -// ATProtoHandler wraps an HTTP handler to provide name resolution -// This is an optional layer if middleware doesn't provide enough control -type ATProtoHandler struct { - handler http.Handler - directory identity.Directory -} - -// NewATProtoHandler creates a new HTTP handler wrapper -func NewATProtoHandler(handler http.Handler) *ATProtoHandler { - return &ATProtoHandler{ - handler: handler, - directory: identity.DefaultDirectory(), - } -} - -// ServeHTTP handles HTTP requests with name resolution -func (h *ATProtoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Parse the request path to extract user/image - // OCI Distribution API paths look like: - // /v2//manifests/ - // /v2//blobs/ - - path := r.URL.Path - - // Check if this is a v2 API request - if strings.HasPrefix(path, "/v2/") { - // Extract the repository name - parts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/") - if len(parts) >= 2 { - // parts[0] might be username/DID - // We could do early resolution here if needed - // For now, we'll let the middleware handle it - } - } - - // Delegate to the underlying handler - // The registry middleware will handle the actual resolution - h.handler.ServeHTTP(w, r) -} - -// Note: In the current architecture, most of the name resolution -// is handled by the registry middleware. This HTTP handler wrapper -// is here for cases where you need to intercept requests before -// they reach the distribution handlers, such as for: -// - Custom authentication based on DIDs -// - Request rewriting -// - Early validation -// - Custom API endpoints beyond OCI spec