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:
RXWatcher
2026-05-26 06:47:33 +02:00
co-authored by Claude Opus 4.7
parent f967fad9ff
commit cd4f62965c
2 changed files with 193 additions and 16 deletions
+74 -3
View File
@@ -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
}
+119 -13
View File
@@ -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,
})
}