···11-package main
22-33-import (
44- "context"
55- "encoding/base64"
66- "encoding/json"
77- "flag"
88- "fmt"
99- "log"
1010- "os"
1111- "path/filepath"
1212- "strings"
1313-1414- "atcr.io/pkg/atproto"
1515- atprotoAuth "atcr.io/pkg/auth/atproto"
1616-)
1717-1818-// DockerConfig represents ~/.docker/config.json
1919-type DockerConfig struct {
2020- Auths map[string]AuthEntry `json:"auths"`
2121-}
2222-2323-type AuthEntry struct {
2424- Auth string `json:"auth"` // base64(username:password)
2525-}
2626-2727-func main() {
2828- var defaultHold string
2929- var registryURL string
3030-3131- flag.StringVar(&defaultHold, "default-hold", "", "Default hold endpoint URL (e.g., http://172.28.0.3:8080)")
3232- flag.StringVar(®istryURL, "registry", "127.0.0.1:5000", "Registry URL to read auth from Docker config")
3333- flag.Parse()
3434-3535- // Read Docker config
3636- home, err := os.UserHomeDir()
3737- if err != nil {
3838- log.Fatalf("Failed to get home directory: %v", err)
3939- }
4040- dockerConfigPath := filepath.Join(home, ".docker", "config.json")
4141-4242- configData, err := os.ReadFile(dockerConfigPath)
4343- if err != nil {
4444- log.Fatalf("Failed to read Docker config: %v\n\nMake sure you've logged in with: docker login %s", err, registryURL)
4545- }
4646-4747- var dockerConfig DockerConfig
4848- if err := json.Unmarshal(configData, &dockerConfig); err != nil {
4949- log.Fatalf("Failed to parse Docker config: %v", err)
5050- }
5151-5252- // Get auth for registry
5353- authEntry, ok := dockerConfig.Auths[registryURL]
5454- if !ok {
5555- log.Fatalf("No auth found for registry %s in Docker config", registryURL)
5656- }
5757-5858- // Decode base64 auth (format: "username:password")
5959- authBytes, err := base64.StdEncoding.DecodeString(authEntry.Auth)
6060- if err != nil {
6161- log.Fatalf("Failed to decode auth: %v", err)
6262- }
6363-6464- parts := strings.SplitN(string(authBytes), ":", 2)
6565- if len(parts) != 2 {
6666- log.Fatalf("Invalid auth format")
6767- }
6868-6969- handle := parts[0]
7070- password := parts[1] // This should be an app password
7171-7272- fmt.Printf("Handle: %s\n", handle)
7373-7474- // Create session validator and get access token
7575- validator := atprotoAuth.NewSessionValidator()
7676- ctx := context.Background()
7777-7878- did, pdsEndpoint, accessToken, err := validator.CreateSessionAndGetToken(ctx, handle, password)
7979- if err != nil {
8080- log.Fatalf("Failed to authenticate: %v", err)
8181- }
8282-8383- fmt.Printf("DID: %s\n", did)
8484- fmt.Printf("PDS: %s\n\n", pdsEndpoint)
8585-8686- // Create client with the access token from createSession
8787- client := atproto.NewClient(pdsEndpoint, did, accessToken)
8888-8989- // Get current profile
9090- profile, err := atproto.GetProfile(ctx, client)
9191- if err != nil {
9292- log.Fatalf("Failed to get current profile: %v", err)
9393- }
9494-9595- if profile == nil {
9696- if defaultHold == "" {
9797- fmt.Println("No existing profile found.")
9898- fmt.Println("\nTo create profile with default hold, use: -default-hold <url>")
9999- return
100100- }
101101- fmt.Println("No existing profile found. Creating new profile...")
102102- profile = atproto.NewSailorProfileRecord(defaultHold)
103103- } else {
104104- fmt.Printf("Current defaultHold: %s\n", profile.DefaultHold)
105105- if defaultHold == "" {
106106- // Just show current profile
107107- fmt.Println("\nTo update, use: -default-hold <url>")
108108- return
109109- }
110110- profile.DefaultHold = defaultHold
111111- }
112112-113113- // Update profile
114114- if defaultHold != "" {
115115- err = atproto.UpdateProfile(ctx, client, profile)
116116- if err != nil {
117117- log.Fatalf("Failed to update profile: %v", err)
118118- }
119119-120120- fmt.Printf("\n✓ Updated defaultHold to: %s\n", defaultHold)
121121- }
122122-}
+6-40
pkg/auth/atproto/session.go
···1919// CachedSession represents a cached session
2020type CachedSession struct {
2121 DID string
2222+ Handle string
2223 PDS string
2324 AccessToken string
2425 ExpiresAt time.Time
···8384 AccessToken string `json:"access_token,omitempty"` // Alternative field name
8485}
85868686-// ValidateCredentials validates username and password against ATProto
8787-// Returns the user's DID and PDS endpoint if valid
8888-func (v *SessionValidator) ValidateCredentials(ctx context.Context, identifier, password string) (did, pdsEndpoint string, err error) {
8989- // Resolve identifier (handle or DID) to PDS endpoint
9090- atID, err := syntax.ParseAtIdentifier(identifier)
9191- if err != nil {
9292- return "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err)
9393- }
9494-9595- ident, err := v.directory.Lookup(ctx, *atID)
9696- if err != nil {
9797- return "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err)
9898- }
9999-100100- resolvedDID := ident.DID.String()
101101- pds := ident.PDSEndpoint()
102102- if pds == "" {
103103- return "", "", fmt.Errorf("no PDS endpoint found for %q", identifier)
104104- }
105105-106106- fmt.Printf("DEBUG: Resolved %s to DID=%s, PDS=%s\n", identifier, resolvedDID, pds)
107107-108108- // Create session with the PDS
109109- fmt.Printf("DEBUG [atproto/session]: Creating session for %s at PDS %s\n", identifier, pds)
110110- sessionResp, err := v.createSession(ctx, pds, identifier, password)
111111- if err != nil {
112112- fmt.Printf("DEBUG [atproto/session]: Session creation failed: %v\n", err)
113113- return "", "", fmt.Errorf("authentication failed for %s at PDS %s: %w", identifier, pds, err)
114114- }
115115-116116- fmt.Printf("DEBUG [atproto/session]: Session created successfully, DID=%s, Handle=%s, AccessJWT length=%d\n",
117117- sessionResp.DID, sessionResp.Handle, len(sessionResp.AccessJWT))
118118-119119- return sessionResp.DID, pds, nil
120120-}
121121-122122-// CreateSessionAndGetToken creates a session and returns the DID, PDS endpoint, and access token
123123-func (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identifier, password string) (did, pdsEndpoint, accessToken string, err error) {
8787+// CreateSessionAndGetToken creates a session and returns the DID, handle, and access token
8888+func (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identifier, password string) (did, handle, accessToken string, err error) {
12489 // Check cache first
12590 cacheKey := getCacheKey(identifier, password)
12691 if cached, ok := v.getCachedSession(cacheKey); ok {
12792 fmt.Printf("DEBUG [atproto/session]: Using cached session for %s (DID=%s)\n", identifier, cached.DID)
128128- return cached.DID, cached.PDS, cached.AccessToken, nil
9393+ return cached.DID, cached.Handle, cached.AccessToken, nil
12994 }
1309513196 fmt.Printf("DEBUG [atproto/session]: No cached session for %s, creating new session\n", identifier)
···156121 // Cache the session (ATProto sessions typically last 2 hours)
157122 v.setCachedSession(cacheKey, &CachedSession{
158123 DID: sessionResp.DID,
124124+ Handle: sessionResp.Handle,
159125 PDS: pds,
160126 AccessToken: sessionResp.AccessJWT,
161127 ExpiresAt: time.Now().Add(2 * time.Hour),
162128 })
163129 fmt.Printf("DEBUG [atproto/session]: Cached session for %s (expires in 2 hours)\n", identifier)
164130165165- return sessionResp.DID, pds, sessionResp.AccessJWT, nil
131131+ return sessionResp.DID, sessionResp.Handle, sessionResp.AccessJWT, nil
166132}
167133168134// createSession calls com.atproto.server.createSession
-112
pkg/auth/atproto/validator.go
···11-package atproto
22-33-import (
44- "context"
55- "encoding/json"
66- "fmt"
77- "io"
88- "net/http"
99-1010- "github.com/bluesky-social/indigo/atproto/identity"
1111- "github.com/bluesky-social/indigo/atproto/syntax"
1212-)
1313-1414-// TokenValidator validates ATProto OAuth access tokens
1515-type TokenValidator struct {
1616- httpClient *http.Client
1717-}
1818-1919-// NewTokenValidator creates a new token validator
2020-func NewTokenValidator() *TokenValidator {
2121- return &TokenValidator{
2222- httpClient: &http.Client{},
2323- }
2424-}
2525-2626-// SessionInfo represents the response from com.atproto.server.getSession
2727-type SessionInfo struct {
2828- DID string `json:"did"`
2929- Handle string `json:"handle"`
3030- Email string `json:"email,omitempty"`
3131- EmailConfirmed bool `json:"emailConfirmed,omitempty"`
3232- Active bool `json:"active,omitempty"`
3333-}
3434-3535-// ValidateToken validates an ATProto OAuth access token by calling getSession
3636-// Returns the user's DID and handle if the token is valid
3737-// dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer
3838-func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken, dpopProof string) (*SessionInfo, error) {
3939- // Call com.atproto.server.getSession with the access token
4040- url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", pdsEndpoint)
4141-4242- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
4343- if err != nil {
4444- return nil, fmt.Errorf("failed to create request: %w", err)
4545- }
4646-4747- // Always use Bearer auth for getSession validation
4848- // The DPoP proof from the client is bound to their request to us (POST /auth/exchange),
4949- // not to our request to the PDS (GET /getSession)
5050- req.Header.Set("Authorization", "Bearer "+accessToken)
5151-5252- fmt.Printf("DEBUG [validator]: calling %s with Bearer auth, token_prefix=%s...\n",
5353- url, accessToken[:min(20, len(accessToken))])
5454-5555- resp, err := v.httpClient.Do(req)
5656- if err != nil {
5757- return nil, fmt.Errorf("failed to get session: %w", err)
5858- }
5959- defer resp.Body.Close()
6060-6161- // Read body once for both logging and error handling
6262- bodyBytes, _ := io.ReadAll(resp.Body)
6363-6464- if resp.StatusCode == http.StatusUnauthorized {
6565- fmt.Printf("DEBUG [validator]: getSession returned 401: %s\n", string(bodyBytes))
6666- return nil, fmt.Errorf("invalid or expired token")
6767- }
6868-6969- if resp.StatusCode != http.StatusOK {
7070- fmt.Printf("DEBUG [validator]: getSession failed with status %d: %s\n", resp.StatusCode, string(bodyBytes))
7171- return nil, fmt.Errorf("getSession failed with status %d: %s", resp.StatusCode, string(bodyBytes))
7272- }
7373-7474- var session SessionInfo
7575- if err := json.Unmarshal(bodyBytes, &session); err != nil {
7676- return nil, fmt.Errorf("failed to decode session: %w", err)
7777- }
7878-7979- // Validate required fields
8080- if session.DID == "" {
8181- return nil, fmt.Errorf("session response missing DID")
8282- }
8383- if session.Handle == "" {
8484- return nil, fmt.Errorf("session response missing handle")
8585- }
8686-8787- return &session, nil
8888-}
8989-9090-// ValidateTokenWithResolver validates a token and automatically resolves the PDS endpoint
9191-// dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer
9292-func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken, dpopProof string) (*SessionInfo, error) {
9393- // Resolve handle to PDS endpoint
9494- directory := identity.DefaultDirectory()
9595- atID, err := syntax.ParseAtIdentifier(handle)
9696- if err != nil {
9797- return nil, fmt.Errorf("invalid identifier %q: %w", handle, err)
9898- }
9999-100100- ident, err := directory.Lookup(ctx, *atID)
101101- if err != nil {
102102- return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err)
103103- }
104104-105105- pdsEndpoint := ident.PDSEndpoint()
106106- if pdsEndpoint == "" {
107107- return nil, fmt.Errorf("no PDS endpoint found for %q", handle)
108108- }
109109-110110- // Validate token against the PDS
111111- return v.ValidateToken(ctx, pdsEndpoint, accessToken, dpopProof)
112112-}
-87
pkg/auth/oauth/interactive.go
···44 "context"
55 "fmt"
66 "net/http"
77- "net/url"
88- "sync"
97 "time"
108119 "github.com/bluesky-social/indigo/atproto/auth/oauth"
···1614 SessionData *oauth.ClientSessionData
1715 Session *oauth.ClientSession
1816 App *App
1919-}
2020-2121-// RunInteractiveFlow runs an interactive OAuth flow for CLI tools
2222-// This is a simplified wrapper around indigo's OAuth flow
2323-func RunInteractiveFlow(
2424- ctx context.Context,
2525- baseURL string,
2626- handle string,
2727- scopes []string,
2828- onAuthURL func(string) error,
2929-) (*InteractiveResult, error) {
3030- // Create temporary file store for this flow
3131- store, err := NewFileStore("/tmp/atcr-oauth-temp.json")
3232- if err != nil {
3333- return nil, fmt.Errorf("failed to create OAuth store: %w", err)
3434- }
3535-3636- // Create OAuth app
3737- app, err := NewApp(baseURL, store)
3838- if err != nil {
3939- return nil, fmt.Errorf("failed to create OAuth app: %w", err)
4040- }
4141-4242- // Set custom scopes if provided
4343- if len(scopes) > 0 {
4444- // Note: indigo's ClientApp doesn't expose SetScopes, so we need to use default scopes
4545- // This is a limitation of the current implementation
4646- // TODO: Enhance if custom scopes are needed
4747- }
4848-4949- // Start auth flow
5050- authURL, err := app.StartAuthFlow(ctx, handle)
5151- if err != nil {
5252- return nil, fmt.Errorf("failed to start auth flow: %w", err)
5353- }
5454-5555- // Call the callback to display the auth URL
5656- if err := onAuthURL(authURL); err != nil {
5757- return nil, fmt.Errorf("auth URL callback failed: %w", err)
5858- }
5959-6060- // Wait for OAuth callback
6161- // The callback will be handled by the http.HandleFunc registered by the caller
6262- // We need to wait for ProcessCallback to be called
6363- // This is a bit awkward, but matches the old pattern
6464-6565- // Setup a channel to receive callback params
6666- callbackChan := make(chan url.Values, 1)
6767- var setupOnce sync.Once
6868-6969- // Return a function that the caller can use to process the callback
7070- // This is called from the HTTP handler
7171- processCallback := func(params url.Values) (*oauth.ClientSessionData, error) {
7272- setupOnce.Do(func() {
7373- callbackChan <- params
7474- })
7575- sessionData, err := app.ProcessCallback(ctx, params)
7676- if err != nil {
7777- return nil, fmt.Errorf("failed to process callback: %w", err)
7878- }
7979- return sessionData, nil
8080- }
8181-8282- // Wait for callback with timeout
8383- select {
8484- case params := <-callbackChan:
8585- sessionData, err := processCallback(params)
8686- if err != nil {
8787- return nil, err
8888- }
8989-9090- // Resume session to get ClientSession
9191- session, err := app.ResumeSession(ctx, sessionData.AccountDID, sessionData.SessionID)
9292- if err != nil {
9393- return nil, fmt.Errorf("failed to resume session: %w", err)
9494- }
9595-9696- return &InteractiveResult{
9797- SessionData: sessionData,
9898- Session: session,
9999- App: app,
100100- }, nil
101101- case <-time.After(5 * time.Minute):
102102- return nil, fmt.Errorf("OAuth flow timed out after 5 minutes")
103103- }
10417}
1051810619// InteractiveFlowWithCallback runs an interactive OAuth flow with explicit callback handling
-97
pkg/auth/oauth/refresher.go
···33import (
44 "context"
55 "fmt"
66- "net/http"
76 "sync"
8798 "github.com/bluesky-social/indigo/atproto/auth/oauth"
···7473 return r.resumeSession(ctx, did)
7574}
76757777-// GetAccessToken gets a fresh access token for a DID
7878-// This is a convenience method that extracts the access token from the session
7979-func (r *Refresher) GetAccessToken(ctx context.Context, did string) (string, error) {
8080- session, err := r.GetSession(ctx, did)
8181- if err != nil {
8282- return "", err
8383- }
8484-8585- // Get access token and DPoP nonce from session
8686- accessToken, _ := session.GetHostAccessData()
8787- return accessToken, nil
8888-}
8989-9090-// GetHTTPClient returns an HTTP client with DPoP authentication for a DID
9191-// The client automatically adds DPoP headers and refreshes tokens as needed
9292-func (r *Refresher) GetHTTPClient(ctx context.Context, did string) (*http.Client, error) {
9393- session, err := r.GetSession(ctx, did)
9494- if err != nil {
9595- return nil, err
9696- }
9797-9898- // Get API client from session
9999- // This client automatically handles DPoP and token refresh
100100- apiClient := session.APIClient()
101101- return apiClient.Client, nil
102102-}
103103-10476// resumeSession loads a session from storage and caches it
10577func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.ClientSession, error) {
10678 // Parse DID
···153125 delete(r.sessions, did)
154126 r.mu.Unlock()
155127}
156156-157157-// RevokeSession removes a session from both cache and storage
158158-func (r *Refresher) RevokeSession(ctx context.Context, did string) error {
159159- // Remove from cache
160160- r.mu.Lock()
161161- cached, ok := r.sessions[did]
162162- delete(r.sessions, did)
163163- r.mu.Unlock()
164164-165165- if !ok {
166166- // Not cached, still try to delete from storage
167167- accountDID, err := syntax.ParseDID(did)
168168- if err != nil {
169169- return fmt.Errorf("failed to parse DID: %w", err)
170170- }
171171-172172- // Find session ID from store
173173- fileStore, ok := r.app.clientApp.Store.(*FileStore)
174174- if !ok {
175175- return fmt.Errorf("store is not a FileStore")
176176- }
177177-178178- sessions := fileStore.ListSessions()
179179- for _, sessionData := range sessions {
180180- if sessionData.AccountDID.String() == did {
181181- return r.app.clientApp.Store.DeleteSession(ctx, accountDID, sessionData.SessionID)
182182- }
183183- }
184184-185185- return fmt.Errorf("no session found for DID: %s", did)
186186- }
187187-188188- // Revoke the session via OAuth
189189- if err := cached.Session.RevokeSession(ctx); err != nil {
190190- fmt.Printf("WARNING: failed to revoke session for %s: %v\n", did, err)
191191- // Continue anyway to delete from storage
192192- }
193193-194194- // Delete from storage
195195- accountDID, err := syntax.ParseDID(did)
196196- if err != nil {
197197- return fmt.Errorf("failed to parse DID: %w", err)
198198- }
199199-200200- return r.app.clientApp.Store.DeleteSession(ctx, accountDID, cached.SessionID)
201201-}
202202-203203-// CleanupExpiredSessions removes expired sessions from cache
204204-// Note: indigo handles token expiry automatically, but we clean up orphaned cache entries
205205-func (r *Refresher) CleanupExpiredSessions(ctx context.Context) {
206206- r.mu.Lock()
207207- defer r.mu.Unlock()
208208-209209- // For each cached session, verify it still exists in storage
210210- for did, cached := range r.sessions {
211211- accountDID, err := syntax.ParseDID(did)
212212- if err != nil {
213213- delete(r.sessions, did)
214214- continue
215215- }
216216-217217- // Try to get session from store
218218- _, err = r.app.clientApp.Store.GetSession(ctx, accountDID, cached.SessionID)
219219- if err != nil {
220220- // Session no longer exists, remove from cache
221221- delete(r.sessions, did)
222222- }
223223- }
224224-}
+3-25
pkg/auth/oauth/server.go
···11package oauth
2233import (
44- "context"
54 "fmt"
65 "html/template"
76 "net/http"
87 "time"
99-1010- "github.com/bluesky-social/indigo/atproto/syntax"
118)
1291310// UISessionStore is the interface for UI session management
···10299 fmt.Printf("DEBUG [oauth/server]: Invalidated cached session for DID=%s after creating new session\n", did)
103100 }
104101105105- // We need to get the handle for UI sessions and settings redirect
106106- // Resolve DID to handle using our resolver
107107- handle, err := s.resolveHandle(r.Context(), did)
102102+ // Look up identity
103103+ ident, err := s.app.directory.LookupDID(r.Context(), sessionData.AccountDID)
104104+ handle := ident.Handle.String()
108105 if err != nil {
109106 fmt.Printf("WARNING [oauth/server]: Failed to resolve DID to handle: %v, using DID as handle\n", err)
110107 handle = did // Fallback to DID if resolution fails
···150147151148 // Non-UI flow: redirect to settings to get API key
152149 s.renderRedirectToSettings(w, handle)
153153-}
154154-155155-// resolveHandle attempts to resolve a DID to a handle
156156-// This is a best-effort helper - we use the directory to look up the handle
157157-func (s *Server) resolveHandle(ctx context.Context, didStr string) (string, error) {
158158- // Parse DID
159159- did, err := syntax.ParseDID(didStr)
160160- if err != nil {
161161- return "", fmt.Errorf("invalid DID: %w", err)
162162- }
163163-164164- // Look up identity
165165- ident, err := s.app.directory.LookupDID(ctx, did)
166166- if err != nil {
167167- return "", fmt.Errorf("failed to lookup DID: %w", err)
168168- }
169169-170170- // Return handle (may be handle.invalid if verification failed)
171171- return ident.Handle.String(), nil
172150}
173151174152// renderRedirectToSettings redirects to the settings page to generate an API key
-57
pkg/server/handler.go
···11-package server
22-33-import (
44- "net/http"
55- "strings"
66-77- "github.com/bluesky-social/indigo/atproto/identity"
88-)
99-1010-// ATProtoHandler wraps an HTTP handler to provide name resolution
1111-// This is an optional layer if middleware doesn't provide enough control
1212-type ATProtoHandler struct {
1313- handler http.Handler
1414- directory identity.Directory
1515-}
1616-1717-// NewATProtoHandler creates a new HTTP handler wrapper
1818-func NewATProtoHandler(handler http.Handler) *ATProtoHandler {
1919- return &ATProtoHandler{
2020- handler: handler,
2121- directory: identity.DefaultDirectory(),
2222- }
2323-}
2424-2525-// ServeHTTP handles HTTP requests with name resolution
2626-func (h *ATProtoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
2727- // Parse the request path to extract user/image
2828- // OCI Distribution API paths look like:
2929- // /v2/<name>/manifests/<reference>
3030- // /v2/<name>/blobs/<digest>
3131-3232- path := r.URL.Path
3333-3434- // Check if this is a v2 API request
3535- if strings.HasPrefix(path, "/v2/") {
3636- // Extract the repository name
3737- parts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
3838- if len(parts) >= 2 {
3939- // parts[0] might be username/DID
4040- // We could do early resolution here if needed
4141- // For now, we'll let the middleware handle it
4242- }
4343- }
4444-4545- // Delegate to the underlying handler
4646- // The registry middleware will handle the actual resolution
4747- h.handler.ServeHTTP(w, r)
4848-}
4949-5050-// Note: In the current architecture, most of the name resolution
5151-// is handled by the registry middleware. This HTTP handler wrapper
5252-// is here for cases where you need to intercept requests before
5353-// they reach the distribution handlers, such as for:
5454-// - Custom authentication based on DIDs
5555-// - Request rewriting
5656-// - Early validation
5757-// - Custom API endpoints beyond OCI spec