navidrome/server/apiv1/problem_test.go
Deluan d1b876097f refactor(api): use the grant secret as the API v1 bearer credential
API v1 no longer mints short-lived JWT access tokens. Clients send the grant
secret from POST /auth/login or /auth/setup as `Authorization: Bearer` on
every request.

Every request already looked the grant up in the database, so the JWT gave
no speed or revocation benefit and only added a refresh loop, which early
client authors pushed back on. The grant already is an API key: one per
client sign-in, scoped and revocable. Revocation is now immediate on every
node; the contract promises "within one minute".

Removed: POST /auth/token, the grantAuth scheme, the TokenRequest and
AccessToken schemas, the token_expired problem code, the API v1 JWT signer
and its signing key, the grant liveness cache, and PropertyRepository.PutIfAbsent.
ResolveGrant is now Authenticate.

Short-lived tokens return later only as narrow media tokens for
?access_token= on media URLs, together with the media endpoints.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-28 21:05:57 -04:00

172 lines
6.9 KiB
Go

package apiv1
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/core/apiauth"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func decodeProblem(w *httptest.ResponseRecorder) Problem {
var p Problem
ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), &p)).To(Succeed())
return p
}
var _ = Describe("problem", func() {
var ctx context.Context
var w *httptest.ResponseRecorder
var r *http.Request
BeforeEach(func() {
ctx = GinkgoT().Context()
w = httptest.NewRecorder()
r = httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/server", nil)
})
Describe("writeProblem", func() {
DescribeTable("maps domain errors to status and code",
func(err error, status int, code ProblemCode) {
writeProblem(w, r, err)
Expect(w.Code).To(Equal(status))
Expect(w.Header().Get("Content-Type")).To(Equal(problemContentType))
p := decodeProblem(w)
Expect(p.Status).To(Equal(status))
Expect(p.Code).To(Equal(code))
Expect(p.Title).To(Equal(http.StatusText(status)))
Expect(p.Type).To(BeNil())
Expect(w.Body.String()).ToNot(ContainSubstring(`"type"`))
},
Entry("not found", model.ErrNotFound, http.StatusNotFound, ProblemCodeNotFound),
Entry("not authorized", model.ErrNotAuthorized, http.StatusForbidden, ProblemCodeForbidden),
Entry("invalid auth", model.ErrInvalidAuth, http.StatusUnauthorized, ProblemCodeUnauthorized),
Entry("expired", model.ErrExpired, http.StatusUnauthorized, ProblemCodeUnauthorized),
Entry("validation", model.ErrValidation, http.StatusBadRequest, ProblemCodeValidation),
Entry("not available", model.ErrNotAvailable, http.StatusServiceUnavailable, ProblemCodeUnavailable),
Entry("insufficient scope", apiauth.ErrInsufficientScope, http.StatusForbidden, ProblemCodeInsufficientScope),
Entry("setup complete", auth.ErrSetupComplete, http.StatusConflict, ProblemCodeSetupComplete),
Entry("password managed externally", apiauth.ErrPasswordManagedExternally, http.StatusConflict, ProblemCodePasswordManagedExternally),
Entry("unknown", errors.New("boom"), http.StatusInternalServerError, ProblemCodeInternal),
)
It("shows detail only for errors marked as client-facing", func() {
writeProblem(w, r, fmt.Errorf("album 123: %w", model.ErrNotFound))
Expect(decodeProblem(w).Detail).To(BeNil())
w = httptest.NewRecorder()
writeProblem(w, r, ClientError(model.ErrNotFound, "album not found"))
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeNotFound))
Expect(*p.Detail).To(Equal("album not found"))
})
It("writes field errors from validationFailed", func() {
writeProblem(w, r, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"}))
p := decodeProblem(w)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"}))
})
It("writes and logs a problem with debug logging on", func() {
logs := captureLogs()
writeProblem(w, r, model.ErrNotFound)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(logs.String()).To(ContainSubstring("code=not_found"))
})
It("adds a Bearer challenge to every 401 unless one is already set", func() {
writeProblem(w, r, model.ErrInvalidAuth)
Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
w = httptest.NewRecorder()
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
writeProblem(w, r, model.ErrInvalidAuth)
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
})
It("challenges a 401 with invalid_token when the request carried a bearer token", func() {
r.Header.Set("Authorization", "Bearer tok")
writeProblem(w, r, model.ErrInvalidAuth)
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
w = httptest.NewRecorder()
r.Header.Set("Authorization", "Basic dXNlcjpwdw==")
writeProblem(w, r, model.ErrInvalidAuth)
Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
})
It("adds the request's referenceId to internal errors only", func() {
r = r.WithContext(withReferenceID(r.Context(), "ref-123"))
writeProblem(w, r, errors.New("boom"))
Expect(*decodeProblem(w).ReferenceId).To(Equal("ref-123"))
w = httptest.NewRecorder()
writeProblem(w, r, model.ErrNotFound)
Expect(decodeProblem(w).ReferenceId).To(BeNil())
})
It("hides details for internal errors", func() {
writeProblem(w, r, errors.New("db password is hunter2"))
p := decodeProblem(w)
Expect(p.Detail).To(BeNil())
})
})
Describe("writeProblemStatus", func() {
It("writes field errors only when provided", func() {
writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "bad input",
ValidationError{Field: "limit", Message: "must be <= 2000"})
p := decodeProblem(w)
Expect(p.Errors).ToNot(BeNil())
Expect(*p.Errors).To(HaveLen(1))
Expect((*p.Errors)[0].Field).To(Equal("limit"))
})
It("omits detail when empty", func() {
writeProblemStatus(w, r, http.StatusMethodNotAllowed, ProblemCodeMethodNotAllowed, "")
Expect(w.Body.String()).ToNot(ContainSubstring(`"detail"`))
Expect(w.Body.String()).ToNot(ContainSubstring(`"errors"`))
})
})
Describe("bindingErrorHandler", func() {
DescribeTable("maps parameter binding errors to a validation problem with the field",
func(err error, field, message string) {
bindingErrorHandler(w, r, err)
Expect(w.Code).To(Equal(http.StatusBadRequest))
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(p.Errors).ToNot(BeNil())
Expect(*p.Errors).To(HaveLen(1))
Expect((*p.Errors)[0].Field).To(Equal(field))
Expect((*p.Errors)[0].Message).To(Equal(message))
},
Entry("required", &RequiredParamError{ParamName: "limit"}, "limit", "is required"),
Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New(`parsing "abc": invalid syntax`)}, "offset", "has an invalid value"),
Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "expected a single value"),
Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "has an invalid value"),
)
It("never echoes the submitted value", func() {
bindingErrorHandler(w, r, &InvalidParamFormatError{ParamName: "offset", Err: errors.New(`parsing "hunter2": invalid syntax`)})
Expect(w.Body.String()).ToNot(ContainSubstring("hunter2"))
})
It("still returns a validation problem for unknown binding errors", func() {
bindingErrorHandler(w, r, errors.New("weird"))
p := decodeProblem(w)
Expect(p.Status).To(Equal(http.StatusBadRequest))
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(p.Errors).To(BeNil())
})
})
})