fix app-passwords remove dead code

This commit is contained in:
Evan Jarrett
2025-10-07 13:25:50 -05:00
parent 336dd5f99a
commit b085d53ca7
7 changed files with 9 additions and 540 deletions
-122
View File
@@ -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(&registryURL, "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 <url>")
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 <url>")
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)
}
}
+6 -40
View File
@@ -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
-112
View File
@@ -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)
}
-87
View File
@@ -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(
-97
View File
@@ -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)
}
}
}
+3 -25
View File
@@ -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))
-57
View File
@@ -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/<name>/manifests/<reference>
// /v2/<name>/blobs/<digest>
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