From c80b5b2941f518738c0e8deddcab69c8c2c1883f Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Mon, 5 Jan 2026 21:47:30 -0600 Subject: [PATCH] fix oauth login on admin panel for production --- pkg/hold/admin/admin.go | 69 +++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/pkg/hold/admin/admin.go b/pkg/hold/admin/admin.go index 48bc8a4..9eb7156 100644 --- a/pkg/hold/admin/admin.go +++ b/pkg/hold/admin/admin.go @@ -11,6 +11,7 @@ import ( "crypto/rand" "embed" "encoding/base64" + "encoding/json" "fmt" "html/template" "io/fs" @@ -83,45 +84,50 @@ func NewAdminUI(ctx context.Context, holdPDS *pds.HoldPDS, quotaMgr *quota.Manag return nil, fmt.Errorf("PublicURL is required for admin panel") } - // Determine OAuth base URL - // - For IP addresses (Docker network IPs), substitute 127.0.0.1 (ATProto requires localhost for public clients) - // - For real domains, use as-is (works with ATProto OAuth) - oauthBaseURL := cfg.PublicURL + // Determine OAuth configuration based on URL type u, err := url.Parse(cfg.PublicURL) if err != nil { return nil, fmt.Errorf("invalid PublicURL: %w", err) } - host := u.Hostname() - if isIPAddress(host) { - // IP address (e.g., 172.28.0.3) - substitute localhost - port := u.Port() - if port == "" { - port = "8080" - } - oauthBaseURL = "http://127.0.0.1:" + port - } - // Use in-memory store for OAuth sessions oauthStore := indigooauth.NewMemStore() // Use minimal scopes for admin (only need basic auth, no blob access) adminScopes := []string{"atproto"} - // Admin panel uses localhost callback for OAuth (ATProto requirement for public clients) - redirectURI := oauthBaseURL + "/admin/auth/oauth/callback" + var oauthConfig indigooauth.ClientConfig + var redirectURI string - // Use simple public client - no need for confidential client complexity - oauthConfig := indigooauth.NewLocalhostConfig(redirectURI, adminScopes) + host := u.Hostname() + if isIPAddress(host) || host == "localhost" || host == "127.0.0.1" { + // Development mode: IP address or localhost - use localhost OAuth config + // Substitute 127.0.0.1 for Docker network IPs + port := u.Port() + if port == "" { + port = "8080" + } + oauthBaseURL := "http://127.0.0.1:" + port + redirectURI = oauthBaseURL + "/admin/auth/oauth/callback" + oauthConfig = indigooauth.NewLocalhostConfig(redirectURI, adminScopes) + + slog.Info("Admin OAuth configured (localhost mode)", + "redirect_uri", redirectURI, + "public_url", cfg.PublicURL) + } else { + // Production mode: real domain - use public client with metadata endpoint + clientID := cfg.PublicURL + "/admin/oauth-client-metadata.json" + redirectURI = cfg.PublicURL + "/admin/auth/oauth/callback" + oauthConfig = indigooauth.NewPublicConfig(clientID, redirectURI, adminScopes) + + slog.Info("Admin OAuth configured (production mode)", + "client_id", clientID, + "redirect_uri", redirectURI) + } clientApp := indigooauth.NewClientApp(&oauthConfig, oauthStore) clientApp.Dir = atproto.GetDirectory() - slog.Info("Admin OAuth configured", - "redirect_uri", redirectURI, - "public_url", cfg.PublicURL, - "oauth_base_url", oauthBaseURL) - // Parse templates templates, err := parseTemplates() if err != nil { @@ -286,6 +292,9 @@ func (ui *AdminUI) RegisterRoutes(r chi.Router) { staticSub, _ := fs.Sub(staticFS, "static") r.Handle("/admin/static/*", http.StripPrefix("/admin/static/", http.FileServer(http.FS(staticSub)))) + // OAuth client metadata endpoint (required for production OAuth) + r.Get("/admin/oauth-client-metadata.json", ui.handleClientMetadata) + // Public auth routes r.Get("/admin/auth/login", ui.handleLogin) r.Get("/admin/auth/oauth/authorize", ui.handleAuthorize) @@ -320,6 +329,20 @@ func (ui *AdminUI) RegisterRoutes(r chi.Router) { }) } +// handleClientMetadata serves the OAuth client metadata for production deployments +func (ui *AdminUI) handleClientMetadata(w http.ResponseWriter, r *http.Request) { + metadata := ui.clientApp.Config.ClientMetadata() + + // Set client name for display in OAuth consent screen + clientName := "Hold Admin Panel" + metadata.ClientName = &clientName + metadata.ClientURI = &ui.config.PublicURL + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=3600") + json.NewEncoder(w).Encode(metadata) +} + // Close cleans up resources (no-op now, but keeps interface consistent) func (ui *AdminUI) Close() error { return nil