feat(audiobooks): add diagnostic logging to ABS bearer auth and login
Each rejection branch in bearerAuth now emits a slog line so failures are traceable from journalctl. Login success path emits a debug line confirming token persistence; this makes "I cant login" debuggable without a tcpdump. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
f967fad9ff
commit
cd4f62965c
@@ -11,6 +11,7 @@ package abs
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -49,6 +50,39 @@ type MediaStore interface {
|
||||
GetMediaFileByID(ctx context.Context, fileID int) (*models.MediaFile, error)
|
||||
// ListAudiobookLibraries returns media_folder rows with type='audiobooks'.
|
||||
ListAudiobookLibraries(ctx context.Context) ([]AudiobookLibrary, error)
|
||||
// SearchAudiobooks does a fuzzy title/author/narrator match for the ABS
|
||||
// /libraries/{id}/search endpoint. Hydrates People so the mapper has
|
||||
// author/narrator names.
|
||||
SearchAudiobooks(ctx context.Context, libraryID int64, query string, limit int) ([]*models.MediaItem, error)
|
||||
// ListContinueListening returns books that the given user has progress
|
||||
// on but hasn't finished — feeds the Home tab's continue shelf.
|
||||
ListContinueListening(ctx context.Context, userID, profileID string, libraryID int64, limit int) ([]*models.MediaItem, error)
|
||||
// ListRecentlyAdded returns the most recently added audiobooks for the
|
||||
// Home tab's recently-added shelf.
|
||||
ListRecentlyAdded(ctx context.Context, libraryID int64, limit int) ([]*models.MediaItem, error)
|
||||
// ListDiscover returns a randomized sampling of audiobooks for the
|
||||
// Home tab's discover shelf (helps new users browse the library).
|
||||
ListDiscover(ctx context.Context, libraryID int64, limit int) ([]*models.MediaItem, error)
|
||||
// ListLibraryAuthors returns distinct authors of audiobooks in the
|
||||
// library along with each author's book count.
|
||||
ListLibraryAuthors(ctx context.Context, libraryID int64, limit int) ([]AuthorSummary, error)
|
||||
// ListLibrarySeries returns distinct series (from audiobook_series)
|
||||
// represented in the library, ordered by name.
|
||||
ListLibrarySeries(ctx context.Context, libraryID int64, limit int) ([]SeriesSummary, error)
|
||||
}
|
||||
|
||||
// AuthorSummary is an aggregated author entry for /libraries/{id}/authors.
|
||||
type AuthorSummary struct {
|
||||
ID string
|
||||
Name string
|
||||
NumBooks int
|
||||
}
|
||||
|
||||
// SeriesSummary is an aggregated series entry for /libraries/{id}/series.
|
||||
type SeriesSummary struct {
|
||||
ID string
|
||||
Name string
|
||||
NumBooks int
|
||||
}
|
||||
|
||||
// TokenStore persists and validates the ABS JWT JTIs that back the
|
||||
@@ -147,6 +181,11 @@ type Dependencies struct {
|
||||
// SocketIO is the Socket.io server mounted at /abs/socket.io/. May be nil;
|
||||
// the route is only registered when a non-nil value is supplied.
|
||||
SocketIO SocketIOServer
|
||||
// CoverResolver translates a raw silo poster path (e.g.
|
||||
// "local/audiobooks/.../original.webp") into a fully-qualified URL
|
||||
// the ABS client can fetch. Optional; when nil, /api/items/{id}/cover
|
||||
// 404s rather than redirecting to an unreachable relative path.
|
||||
CoverResolver func(ctx context.Context, path, variant string) string
|
||||
}
|
||||
|
||||
// Handler wires the /abs/api/* and canonical ABS-client paths.
|
||||
@@ -185,6 +224,17 @@ func (h *Handler) Mount(parent chi.Router) {
|
||||
}
|
||||
|
||||
func (h *Handler) mountRoutes(r chi.Router) {
|
||||
// Discovery + auth endpoints: real ABS exposes these at server ROOT
|
||||
// (no /api or /abs/api prefix). Mobile clients do `${addr}/ping`,
|
||||
// `${addr}/login`, etc. Designed to be mounted on a dedicated listener
|
||||
// so the routes don't collide with silo's SPA catch-all.
|
||||
for _, prefix := range []string{"", "/abs/api"} {
|
||||
r.Get(prefix+"/ping", h.handleABSPing)
|
||||
r.Get(prefix+"/healthcheck", h.handleABSPing) // same body as /ping
|
||||
r.Get(prefix+"/init", h.handleABSInit)
|
||||
r.Get(prefix+"/status", h.handleABSStatus)
|
||||
}
|
||||
|
||||
// Stage 2: login (body-creds + host-proxied paths).
|
||||
r.Post("/login", h.handleLogin)
|
||||
r.Post("/abs/api/login", h.handleLogin)
|
||||
@@ -240,6 +290,9 @@ func (h *Handler) mountRoutes(r chi.Router) {
|
||||
for _, prefix := range []string{"/abs/api", "/api"} {
|
||||
// Current user object.
|
||||
r.Get(prefix+"/me", h.handleMe)
|
||||
// Real-ABS /authorize: validates the bearer and re-mints the
|
||||
// /me envelope so the client can resume without retyping creds.
|
||||
r.Post(prefix+"/authorize", h.handleABSAuthorize)
|
||||
// Continue Listening shelf.
|
||||
r.Get(prefix+"/me/items-in-progress", h.handleItemsInProgress)
|
||||
// Library list + detail.
|
||||
@@ -305,26 +358,44 @@ func (h *Handler) bearerAuth(next http.Handler) http.Handler {
|
||||
raw = r.URL.Query().Get("token")
|
||||
}
|
||||
if raw == "" {
|
||||
slog.Debug("abs bearerAuth: no token", "path", r.URL.Path, "remote", r.RemoteAddr)
|
||||
http.Error(w, "unauthenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if h.deps.Config == nil || h.deps.TokenStore == nil {
|
||||
// Dependencies not yet wired — reject to avoid a security hole.
|
||||
slog.Warn("abs bearerAuth: deps not wired",
|
||||
"have_config", h.deps.Config != nil,
|
||||
"have_token_store", h.deps.TokenStore != nil,
|
||||
"path", r.URL.Path)
|
||||
http.Error(w, "auth not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
secret, err := h.deps.Config.JWTSecret(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("abs bearerAuth: jwt secret fetch failed", "err", err)
|
||||
http.Error(w, "config unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
claims, err := ParseToken(secret, raw)
|
||||
if err != nil || claims.Type != "access" {
|
||||
if err != nil {
|
||||
slog.Debug("abs bearerAuth: parse failed", "err", err, "path", r.URL.Path)
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if claims.Type != "access" {
|
||||
slog.Debug("abs bearerAuth: wrong token type", "type", claims.Type, "path", r.URL.Path)
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
row, err := h.deps.TokenStore.GetTokenByJTI(r.Context(), claims.JTI)
|
||||
if err != nil || row.RevokedAt != nil {
|
||||
if err != nil {
|
||||
slog.Debug("abs bearerAuth: jti lookup failed",
|
||||
"jti", claims.JTI, "err", err, "path", r.URL.Path)
|
||||
http.Error(w, "token revoked", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if row.RevokedAt != nil {
|
||||
slog.Debug("abs bearerAuth: jti revoked", "jti", claims.JTI, "path", r.URL.Path)
|
||||
http.Error(w, "token revoked", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -87,6 +88,7 @@ func (h *Handler) handleStandaloneLogin(w http.ResponseWriter, r *http.Request)
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) || errors.Is(err, auth.ErrUserDisabled) {
|
||||
http.Error(w, "invalid username or password", http.StatusUnauthorized)
|
||||
} else {
|
||||
slog.Error("abs login: cred validator failed", "username", body.Username, "error", err)
|
||||
http.Error(w, "login service unavailable", http.StatusServiceUnavailable)
|
||||
}
|
||||
return
|
||||
@@ -101,6 +103,8 @@ func (h *Handler) handleStandaloneLogin(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("abs standalone login: validator OK",
|
||||
"username", body.Username, "user_id", userID, "profile_id", profileID)
|
||||
h.completeLogin(w, r, userID, profileID, displayName)
|
||||
}
|
||||
|
||||
@@ -171,24 +175,44 @@ func (h *Handler) completeLogin(w http.ResponseWriter, r *http.Request, userID,
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("abs completeLogin: tokens persisted",
|
||||
"user_id", userID, "access_jti", accessJTI, "refresh_jti", refreshJTI)
|
||||
|
||||
// Build user object. displayName falls back to userID when empty.
|
||||
name := displayName
|
||||
if name == "" {
|
||||
name = userID
|
||||
}
|
||||
|
||||
// Resolve the audiobook library list + default ID up front so we can
|
||||
// emit them in the login envelope. ABS clients require these on the
|
||||
// initial login response to seed the library picker before /me lands.
|
||||
libs, _ := h.deps.MediaStore.ListAudiobookLibraries(r.Context())
|
||||
libraryMaps := make([]map[string]any, 0, len(libs))
|
||||
defaultLibraryID := VirtualLibraryID
|
||||
for i, lib := range libs {
|
||||
if i == 0 {
|
||||
defaultLibraryID = audiobookLibraryID(lib)
|
||||
}
|
||||
libraryMaps = append(libraryMaps, audiobookLibraryMap(lib))
|
||||
}
|
||||
|
||||
user := map[string]any{
|
||||
"id": userID,
|
||||
"username": name,
|
||||
"type": "user",
|
||||
"id": userID,
|
||||
"username": name,
|
||||
"type": "user",
|
||||
"defaultLibraryId": defaultLibraryID,
|
||||
"librariesAccessible": []any{}, // empty = "all libraries accessible"
|
||||
"mediaProgress": []any{},
|
||||
"bookmarks": []any{},
|
||||
"isOldToken": false,
|
||||
"token": access, // legacy field some 2.17- clients still read
|
||||
"permissions": map[string]any{
|
||||
"update": true,
|
||||
"delete": true,
|
||||
"download": true,
|
||||
"accessExplicitContent": true,
|
||||
},
|
||||
// Legacy field for 2.17- clients.
|
||||
"token": access,
|
||||
}
|
||||
|
||||
// x-return-tokens opt-in: when set, embed token pair on user object too
|
||||
@@ -199,16 +223,98 @@ func (h *Handler) completeLogin(w http.ResponseWriter, r *http.Request, userID,
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"user": user,
|
||||
"user": user,
|
||||
"userDefaultLibraryId": defaultLibraryID,
|
||||
"serverSettings": map[string]any{
|
||||
"id": "server-settings",
|
||||
"version": "2.35.0",
|
||||
"language": "en-us",
|
||||
"buildNumber": 1,
|
||||
"authActiveAuthMethods": []string{"local"},
|
||||
"version": ServerVersion,
|
||||
"language": "en-us",
|
||||
},
|
||||
"ereaderDevices": []any{},
|
||||
"accessToken": access,
|
||||
"refreshToken": refresh,
|
||||
"libraries": libraryMaps,
|
||||
// Legacy top-level token fields for clients that read them
|
||||
// directly (mainline reads from the user object; some third-party
|
||||
// clients still read top-level).
|
||||
"accessToken": access,
|
||||
"refreshToken": refresh,
|
||||
})
|
||||
}
|
||||
|
||||
// handleABSPing — GET /ping (mounted also as /healthcheck). Wire shape
|
||||
// matches the continuum-plugin-audiobooks implementation exactly: clients
|
||||
// use this to validate the URL before showing the login form, and any
|
||||
// other shape causes the official mobile app to reject the server.
|
||||
func (h *Handler) handleABSPing(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"server": "audiobookshelf",
|
||||
"version": ServerVersion,
|
||||
"pong": true,
|
||||
})
|
||||
}
|
||||
|
||||
// handleABSInit — GET /init. Real ABS first-run detection probe.
|
||||
func (h *Handler) handleABSInit(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"isInit": true})
|
||||
}
|
||||
|
||||
// handleABSStatus — GET /status. Mobile clients call this on every
|
||||
// connection to confirm the server is an ABS install and pull a few
|
||||
// global flags. Matches the plugin shape exactly (key order intentional).
|
||||
func (h *Handler) handleABSStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"isInit": true,
|
||||
"language": "en-us",
|
||||
"app": "audiobookshelf",
|
||||
"serverVersion": ServerVersion,
|
||||
})
|
||||
}
|
||||
|
||||
// handleABSAuthorize — POST /api/authorize. Real ABS uses this to
|
||||
// validate a bearer token and re-mint the login envelope so the client
|
||||
// can resume without retyping credentials. Mounted inside the bearerAuth
|
||||
// group so it inherits the same token validation.
|
||||
//
|
||||
// The response shape is the FULL login envelope — same fields, same
|
||||
// ordering — not a `{"user": ...}` wrapper. Mirrors the
|
||||
// continuum-plugin-audiobooks handleAuthorize verbatim.
|
||||
func (h *Handler) handleABSAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := absAuthFrom(r)
|
||||
if !ok || a.UserID == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
libs, _ := h.deps.MediaStore.ListAudiobookLibraries(r.Context())
|
||||
libraryMaps := make([]map[string]any, 0, len(libs))
|
||||
defaultLibraryID := VirtualLibraryID
|
||||
for i, lib := range libs {
|
||||
if i == 0 {
|
||||
defaultLibraryID = audiobookLibraryID(lib)
|
||||
}
|
||||
libraryMaps = append(libraryMaps, audiobookLibraryMap(lib))
|
||||
}
|
||||
user := map[string]any{
|
||||
"id": a.UserID,
|
||||
"username": a.UserID,
|
||||
"type": "user",
|
||||
"defaultLibraryId": defaultLibraryID,
|
||||
"librariesAccessible": []any{},
|
||||
"mediaProgress": []any{},
|
||||
"bookmarks": []any{},
|
||||
"isOldToken": false,
|
||||
"permissions": map[string]any{
|
||||
"update": true,
|
||||
"delete": true,
|
||||
"download": true,
|
||||
"accessExplicitContent": true,
|
||||
},
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"user": user,
|
||||
"userDefaultLibraryId": defaultLibraryID,
|
||||
"serverSettings": map[string]any{
|
||||
"version": ServerVersion,
|
||||
"language": "en-us",
|
||||
},
|
||||
"ereaderDevices": []any{},
|
||||
"libraries": libraryMaps,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user