mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-14 13:27:09 +02:00
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>
406 lines
16 KiB
Go
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)
|
|
}
|
|
}
|