package oauth import ( "context" "database/sql" "fmt" "html/template" "net/http" "time" "atcr.io/pkg/appview/db" "atcr.io/pkg/atproto" "github.com/bluesky-social/indigo/atproto/syntax" ) // UISessionStore is the interface for UI session management type UISessionStore interface { Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) } // UserStore is the interface for user management type UserStore interface { UpsertUser(did, handle, pdsEndpoint, avatar string) error } // Server handles OAuth authorization for the AppView type Server struct { app *App refresher *Refresher uiSessionStore UISessionStore db *sql.DB } // NewServer creates a new OAuth server func NewServer(app *App) *Server { return &Server{ app: app, } } // SetRefresher sets the refresher for invalidating session cache func (s *Server) SetRefresher(refresher *Refresher) { s.refresher = refresher } // SetUISessionStore sets the UI session store for web login 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 } // ServeAuthorize handles GET /auth/oauth/authorize func (s *Server) ServeAuthorize(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Get handle from query parameter handle := r.URL.Query().Get("handle") if handle == "" { http.Error(w, "handle parameter required", http.StatusBadRequest) return } fmt.Printf("DEBUG [oauth/server]: Starting OAuth flow for handle=%s\n", handle) // Start auth flow via indigo authURL, err := s.app.StartAuthFlow(r.Context(), handle) if err != nil { fmt.Printf("ERROR [oauth/server]: Failed to start auth flow: %v\n", err) http.Error(w, fmt.Sprintf("failed to start auth flow: %v", err), http.StatusInternalServerError) return } fmt.Printf("DEBUG [oauth/server]: Generated authURL=%s\n", authURL) // Redirect to PDS authorization page // Note: indigo handles state internally via the auth store http.Redirect(w, r, authURL, http.StatusFound) } // ServeCallback handles GET /auth/oauth/callback func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Check for OAuth error if errorParam := r.URL.Query().Get("error"); errorParam != "" { errorDesc := r.URL.Query().Get("error_description") s.renderError(w, fmt.Sprintf("OAuth error: %s - %s", errorParam, errorDesc)) return } // Process OAuth callback via indigo (handles state validation internally) sessionData, err := s.app.ProcessCallback(r.Context(), r.URL.Query()) if err != nil { s.renderError(w, fmt.Sprintf("Failed to process OAuth callback: %v", err)) return } did := sessionData.AccountDID.String() sessionID := sessionData.SessionID fmt.Printf("DEBUG [oauth/server]: OAuth callback successful for DID=%s, sessionID=%s\n", did, sessionID) // Invalidate cached session (if any) since we have a new session with new tokens if s.refresher != nil { s.refresher.InvalidateSession(did) fmt.Printf("DEBUG [oauth/server]: Invalidated cached session for DID=%s after creating new session\n", 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 } // Fetch user's Bluesky profile (including avatar) and store in database if s.db != nil { s.fetchAndStoreAvatar(r.Context(), did, sessionID, handle, sessionData.HostURL) } // Check if this is a UI login (has oauth_return_to cookie) if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil { // Create UI session (30 days to match OAuth refresh token lifetime) // Store OAuth sessionID so we can resume it on next login if store, ok := s.uiSessionStore.(interface { CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) }); ok { uiSessionID, err := store.CreateWithOAuth(did, handle, sessionData.HostURL, sessionID, 30*24*time.Hour) if err != nil { s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err)) return } // Set UI session cookie and redirect (code below) http.SetCookie(w, &http.Cookie{ Name: "atcr_session", Value: uiSessionID, Path: "/", MaxAge: 30 * 86400, // 30 days HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) } else { // Fallback for stores that don't support OAuth sessionID uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour) if err != nil { s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err)) return } // Set UI session cookie http.SetCookie(w, &http.Cookie{ Name: "atcr_session", Value: uiSessionID, Path: "/", MaxAge: 30 * 86400, // 30 days HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) } // Clear the return_to cookie http.SetCookie(w, &http.Cookie{ Name: "oauth_return_to", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, }) // Redirect to return URL returnTo := cookie.Value if returnTo == "" { returnTo = "/" } http.Redirect(w, r, returnTo, http.StatusFound) return } // Non-UI flow: redirect to settings to get API key s.renderRedirectToSettings(w, handle) } // 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)) data := struct { Handle string }{ Handle: handle, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, data); err != nil { http.Error(w, "failed to render template", http.StatusInternalServerError) } } // renderError renders an error page func (s *Server) renderError(w http.ResponseWriter, message string) { tmpl := template.Must(template.New("error").Parse(errorTemplate)) data := struct { Message string }{ Message: message, } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadRequest) if err := tmpl.Execute(w, data); err != nil { http.Error(w, "failed to render template", http.StatusInternalServerError) } } // 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()) // 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) } // HTML templates const redirectToSettingsTemplate = ` Authorization Successful - ATCR

✓ Authorization Successful!

You have successfully authorized ATCR to access your ATProto account: {{.Handle}}

Redirecting to settings page to generate your API key...

If not redirected, click here.

Next Steps:

  1. Generate an API key on the settings page
  2. Copy the API key (shown once!)
  3. Use it with: docker login atcr.io -u {{.Handle}} -p [your-api-key]
` const errorTemplate = ` Authorization Failed - ATCR

✗ Authorization Failed

{{.Message}}

Return to home

`