feat: separate browser callback and mobile app confirm endpoints

- Add GET /mgmt/spotify/callback (no auth) for browser OAuth redirect
- Restore POST /mgmt/spotify/confirm (Basic Auth) for ueberboese mobile app
- Callback returns HTML success/error pages; confirm returns JSON
- Both call the same ExchangeCodeAndStore() logic
This commit is contained in:
Tim Van Wassenhove
2026-02-21 00:21:52 +01:00
committed by Tobias Gesellchen
parent c648027735
commit dc81b0aa81
2 changed files with 49 additions and 4 deletions
+5 -4
View File
@@ -658,10 +658,10 @@ func setupRouter(server *handlers.Server) *chi.Mux {
})
r.Route("/mgmt", func(r chi.Router) {
// OAuth callback — no auth required (browser redirect from Spotify).
// The authorization code is single-use, short-lived, and useless without
// the client_secret (which only the server has).
r.Get("/spotify/confirm", server.HandleMgmtSpotifyConfirm)
// Browser OAuth callback — no auth required (Spotify redirects the
// user's browser here directly). The authorization code is single-use,
// short-lived, and useless without the client_secret.
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
// All other management endpoints require Basic Auth.
r.Group(func(r chi.Router) {
@@ -669,6 +669,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers)
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
r.Post("/spotify/init", server.HandleMgmtSpotifyInit)
r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
+44
View File
@@ -103,7 +103,51 @@ func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, _ *http.Request) {
})
}
// HandleMgmtSpotifyCallback is the browser OAuth callback from Spotify.
// Not protected by Basic Auth — Spotify redirects the user's browser here directly.
// Returns an HTML page the user can close.
func (s *Server) HandleMgmtSpotifyCallback(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`<html><body><h1>Error</h1><p>Spotify integration not configured</p></body></html>`))
return
}
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`<html><body><h1>Spotify Authorization Failed</h1><p>Error: ` + errMsg + `</p></body></html>`))
return
}
code := r.URL.Query().Get("code")
if code == "" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`<html><body><h1>Missing authorization code</h1></body></html>`))
return
}
if err := svc.ExchangeCodeAndStore(code); err != nil {
log.Printf("[Mgmt] Spotify callback failed: %v", err)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`<html><body><h1>Error</h1><p>Token exchange failed</p></body></html>`))
return
}
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<html><body><h1>Spotify Connected</h1><p>You can close this window.</p></body></html>`))
}
// HandleMgmtSpotifyConfirm exchanges an authorization code for tokens.
// Used by the ueberboese mobile app after the deep link callback delivers the code.
// Protected by Basic Auth.
func (s *Server) HandleMgmtSpotifyConfirm(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
svc := s.spotifyService