navidrome/server/apiv1/gate_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

406 lines
16 KiB
Go

package apiv1
import (
"bytes"
"context"
"maps"
"net/http"
"net/http/httptest"
"os"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/apiauth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const gateSpec = `
openapi: 3.0.3
info: {title: t, version: "1"}
paths:
/open:
get: {operationId: open, x-module: core, security: [], responses: {'200': {description: ok}}}
/things/{id}:
get:
operationId: getThing
x-module: core
x-scope: read
security: [{bearerAuth: []}]
parameters: [{name: id, in: path, required: true, schema: {type: string, maxLength: 3}}]
responses: {'200': {description: ok}}
/things:
post:
operationId: createThing
x-module: password
x-scope: password
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: {type: string, maxLength: 5}
tags: {type: array, items: {type: object, properties: {label: {type: string, maxLength: 5}}}}
responses: {'200': {description: ok}}
/caps:
get: {operationId: caps, x-module: core, security: [{bearerAuth: []}], responses: {'200': {description: ok}}}
/limited:
post: {operationId: limited, x-module: core, security: [], responses: {'200': {description: ok}}}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer}
`
type fakeAuth struct {
principal *apiauth.Principal
err error
gotSecret string
gotIP string
}
func (f *fakeAuth) Authenticate(_ context.Context, secret, ip string) (*apiauth.Principal, error) {
f.gotSecret, f.gotIP = secret, ip
return f.principal, f.err
}
var testGateRules = gateRules{
limited: map[string]bool{"limited": true},
noScope: map[string]bool{"caps": true},
noStore: map[string]bool{"caps": true},
}
var _ = Describe("spec gate", func() {
var ctx context.Context
var fa *fakeAuth
var mux *chi.Mux
var g *gate
var reached string
build := func(spec string) (*chi.Mux, error) {
doc, err := openapi3.NewLoader().LoadFromData([]byte(spec))
Expect(err).ToNot(HaveOccurred())
m := chi.NewRouter()
g, err = newGate(doc, m, fa, testGateRules)
if err != nil {
return nil, err
}
m.Use(headAsGet(m), g.handler)
ok := func(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
reached = name
if p, found := apiauth.PrincipalFrom(r.Context()); found {
w.Header().Set("X-User", p.User.ID)
}
w.WriteHeader(http.StatusOK)
}
}
m.Get("/open", ok("open"))
m.Get("/things/{id}", ok("getThing"))
m.Post("/things", ok("createThing"))
m.Get("/caps", ok("caps"))
m.Post("/limited", ok("limited"))
return m, nil
}
do := func(method, path, auth, body string) *httptest.ResponseRecorder {
var req *http.Request
if body != "" {
req = httptest.NewRequestWithContext(ctx, method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequestWithContext(ctx, method, path, nil)
}
if auth != "" {
req.Header.Set("Authorization", auth)
}
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
return w
}
BeforeEach(func() {
ctx = GinkgoT().Context()
reached = ""
fa = &fakeAuth{principal: &apiauth.Principal{User: model.User{ID: "u1"}, GrantID: "g1", Scopes: []string{"read"}}}
var err error
mux, err = build(gateSpec)
Expect(err).ToNot(HaveOccurred())
})
It("lets public operations through without a credential", func() {
Expect(do(http.MethodGet, "/open", "", "").Code).To(Equal(http.StatusOK))
Expect(reached).To(Equal("open"))
})
It("requires a grant secret, with a Bearer challenge", func() {
w := do(http.MethodGet, "/things/1", "", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized))
Expect(reached).To(BeEmpty())
})
It("accepts the Bearer scheme in any case and trims spaces", func() {
w := do(http.MethodGet, "/things/1", "bearer ndg_secret ", "")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.gotSecret).To(Equal("ndg_secret"))
Expect(w.Header().Get("X-User")).To(Equal("u1"))
})
It("maps auth failures to unauthorized with invalid_token", func() {
fa.err = model.ErrInvalidAuth
w := do(http.MethodGet, "/things/1", "Bearer x", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized))
})
It("rejects a grant without the operation's scope", func() {
w := do(http.MethodPost, "/things", "Bearer x", `{"name":"a"}`)
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="password"`))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope))
})
It("lets any valid grant through an operation with no x-scope", func() {
fa.principal.Scopes = nil
Expect(do(http.MethodGet, "/caps", "Bearer x", "").Code).To(Equal(http.StatusOK))
})
It("passes the full client address to the authenticator, not the rate-limit /64", func() {
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/things/1", nil)
req.RemoteAddr = "[2001:db8:1:2:3:4:5:6]:4321"
req.Header.Set("Authorization", "Bearer x")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.gotIP).To(Equal("2001:db8:1:2:3:4:5:6"))
})
It("marks only the listed operations' responses no-store, errors included", func() {
Expect(do(http.MethodGet, "/caps", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store"))
fa.err = model.ErrInvalidAuth
Expect(do(http.MethodGet, "/caps", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store"))
fa.err = nil
Expect(do(http.MethodGet, "/things/1", "Bearer x", "").Header().Get("Cache-Control")).To(BeEmpty())
})
It("checks HEAD on a protected GET", func() {
w := do(http.MethodHead, "/things/1", "", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("looks routes up on the raw path, as chi dispatches them", func() {
w := do(http.MethodGet, "/things/a%2Fb", "", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(reached).To(BeEmpty())
root := chi.NewRouter()
root.Mount("/music/api/v1", mux)
w = httptest.NewRecorder()
root.ServeHTTP(w, httptest.NewRequestWithContext(ctx, http.MethodGet, "/music/api/v1/things/a%2Fb", nil))
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(reached).To(BeEmpty())
})
It("works when mounted under a base path", func() {
root := chi.NewRouter()
root.Mount("/music/api/v1", mux)
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/music/api/v1/things/1", nil)
w := httptest.NewRecorder()
root.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("authenticates before validating", func() {
w := do(http.MethodPost, "/things", "", `{"name":"far-too-long"}`)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("returns and logs sanitised validation errors that never echo the value", func() {
logs := captureLogs()
fa.principal.Scopes = []string{"password"}
w := do(http.MethodPost, "/things", "Bearer x", `{"name":"hunter2-secret"}`)
Expect(w.Code).To(Equal(http.StatusBadRequest))
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "name", Message: "is too long"}))
Expect(w.Body.String()).ToNot(ContainSubstring("hunter2"))
Expect(logs.String()).To(ContainSubstring("failed validation"))
Expect(logs.String()).ToNot(ContainSubstring("hunter2"))
})
It("reports a missing required body field by name", func() {
fa.principal.Scopes = []string{"password"}
w := do(http.MethodPost, "/things", "Bearer x", `{}`)
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "name", Message: "is required"}))
})
DescribeTable("rejects a case variant of a declared body field, which Go would decode into it",
func(body, field string) {
fa.principal.Scopes = []string{"password"}
w := do(http.MethodPost, "/things", "Bearer x", body)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: field, Message: "must match the field name exactly"}))
Expect(reached).To(BeEmpty())
},
Entry("top level", `{"name":"ok","NAME":"much-too-long"}`, "NAME"),
Entry("inside array items", `{"name":"ok","tags":[{"label":"a"},{"label":"b","Label":"much-too-long"}]}`, "tags.1.Label"),
Entry("Unicode case folding", "{\"name\":\"ok\",\"tag\u017f\":null}", "tag\u017f"),
)
DescribeTable("rejects data after the first JSON value, which Go's decoder would ignore",
func(body string) {
fa.principal.Scopes = []string{"password"}
w := do(http.MethodPost, "/things", "Bearer x", body)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "", Message: "must be a single JSON value"}))
Expect(reached).To(BeEmpty())
},
Entry("garbage", `{"name":"ok","NAME":"much-too-long"}x`),
Entry("a second value", `{"name":"ok"} {"NAME":"much-too-long"}`),
Entry("a stray bracket", `{"name":"ok"}]`),
)
DescribeTable("checks the body whatever parameters the Content-Type carries",
func(contentType string) {
fa.principal.Scopes = []string{"password"}
req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/things", strings.NewReader(`{"name":"ok","NAME":"much-too-long"}`))
req.Header.Set("Content-Type", contentType)
req.Header.Set("Authorization", "Bearer x")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "NAME", Message: "must match the field name exactly"}))
Expect(reached).To(BeEmpty())
},
Entry("repeated parameter", "application/json; a=1; a=2"),
Entry("repeated charset", "application/json; charset=utf-8; CHARSET=latin1"),
)
It("accepts trailing whitespace after the JSON value", func() {
fa.principal.Scopes = []string{"password"}
Expect(do(http.MethodPost, "/things", "Bearer x", "{\"name\":\"ok\"}\n \t").Code).To(Equal(http.StatusOK))
})
It("allows unknown body fields that do not collide with a declared one", func() {
fa.principal.Scopes = []string{"password"}
w := do(http.MethodPost, "/things", "Bearer x", `{"name":"ok","extra":{"Name":"x"},"tags":[{"label":"a","other":1}]}`)
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
Expect(reached).To(Equal("createThing"))
})
It("validates path parameters", func() {
w := do(http.MethodGet, "/things/toolong", "Bearer x", "")
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"}))
})
It("passes unknown paths through to the router's 404", func() {
Expect(do(http.MethodGet, "/nope", "", "").Code).To(Equal(http.StatusNotFound))
})
It("fails closed for a routed pattern the spec does not know", func() {
mux.Get("/extra", func(w http.ResponseWriter, r *http.Request) { reached = "extra" })
w := do(http.MethodGet, "/extra", "", "")
Expect(w.Code).To(Equal(http.StatusInternalServerError))
Expect(reached).To(BeEmpty())
})
It("rate-limits the listed operations with a 429 problem", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 1
var err error
mux, err = build(gateSpec)
Expect(err).ToNot(HaveOccurred())
w := do(http.MethodPost, "/limited", "", "")
Expect(w.Code).To(Equal(http.StatusOK))
expectNoXRateLimitHeaders(w)
w = do(http.MethodPost, "/limited", "", "")
Expect(w.Code).To(Equal(http.StatusTooManyRequests))
Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty())
expectNoXRateLimitHeaders(w)
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeRateLimited))
})
DescribeTable("refuses specs that break the security rules",
func(bad string) {
_, err := build(bad)
Expect(err).To(HaveOccurred())
},
Entry("missing security", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core,", 1)),
Entry("scope not matching module", strings.Replace(gateSpec, "x-scope: read", "x-scope: password", 1)),
Entry("unknown scope", strings.Replace(gateSpec, "x-scope: read", "x-scope: bogus", 1)),
Entry("bearer without x-scope outside the allowlist", strings.Replace(gateSpec, " x-scope: read\n", "", 1)),
Entry("a scheme other than bearerAuth", strings.Replace(
strings.Replace(gateSpec, " bearerAuth: {type: http, scheme: bearer}\n", " bearerAuth: {type: http, scheme: bearer}\n grantAuth: {type: http, scheme: bearer}\n", 1),
"operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{grantAuth: []}]", 1)),
Entry("an undeclared scheme", strings.Replace(gateSpec, "operationId: limited, x-module: core, security: []", "operationId: limited, x-module: core, security: [{grantAuth: []}]", 1)),
Entry("non-empty scope list on a bearer scheme", strings.Replace(gateSpec, "operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{bearerAuth: [read]}]", 1)),
Entry("x-scope on a public operation", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core, x-scope: read, security: [],", 1)),
Entry("x-scope that is not a string", strings.Replace(gateSpec, "x-scope: read", "x-scope: [read]", 1)),
Entry("x-scope not in KnownScopes", strings.Replace(gateSpec, "x-module: password\n x-scope: password", "x-module: admin\n x-scope: admin", 1)),
)
DescribeTable("refuses rules that name an operation missing from the spec",
func(set func(*gateRules) *map[string]bool) {
doc, err := openapi3.NewLoader().LoadFromData([]byte(gateSpec))
Expect(err).ToNot(HaveOccurred())
rules := testGateRules
m := set(&rules)
*m = maps.Clone(*m)
(*m)["typo"] = true
_, err = newGate(doc, chi.NewRouter(), fa, rules)
Expect(err).To(MatchError(ContainSubstring("typo")))
},
Entry("limited", func(r *gateRules) *map[string]bool { return &r.limited }),
Entry("noScope", func(r *gateRules) *map[string]bool { return &r.noScope }),
Entry("noStore", func(r *gateRules) *map[string]bool { return &r.noStore }),
)
It("checks routes against the spec in both directions", func() {
Expect(g.checkRoutes()).To(Succeed())
mux.Get("/extra", func(http.ResponseWriter, *http.Request) {})
Expect(g.checkRoutes()).To(MatchError(ContainSubstring("GET /extra is not in the spec")))
extraOp := strings.Replace(gateSpec, "components:", ` /unrouted:
get: {operationId: unrouted, x-module: core, security: [], responses: {'200': {description: ok}}}
components:`, 1)
_, err := build(extraOp)
Expect(err).ToNot(HaveOccurred())
Expect(g.checkRoutes()).To(MatchError(ContainSubstring("unrouted")))
})
})
// captureLogs sends debug logs to a buffer for the rest of the spec.
func captureLogs() *bytes.Buffer {
buf := &bytes.Buffer{}
log.SetOutput(buf)
log.SetLevel(log.LevelDebug)
DeferCleanup(func() {
log.SetOutput(os.Stderr)
log.SetLevel(log.LevelFatal)
})
return buf
}
func expectNoXRateLimitHeaders(w *httptest.ResponseRecorder) {
GinkgoHelper()
for _, h := range []string{"X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Increment", "X-RateLimit-Reset"} {
Expect(w.Header().Values(h)).To(BeEmpty(), h)
}
}