navidrome/server/jellyfin/e2e/auth_test.go
Deluan Quintão 59810c3d59
feat(jellyfin): non-expiring, audience-scoped tokens revocable by password change (#6013)
* feat(auth): add per-user token_epoch column and bump method

* feat(auth): add aud and ep claims, omitted when zero

* feat(auth): add CreateAPIToken for non-expiring, audience-scoped tokens

* feat(auth): add CheckClaims for epoch and audience validation

* feat(jellyfin): issue non-expiring, jellyfin-scoped access tokens

* fix(subsonic): reject API-scoped and revoked tokens on the jwt path

* fix(server): reject API-scoped and revoked tokens on the native API

* fix(server): pin the token-subject guard and stop leaking test config

Adds a regression spec for the DevAutoLogin/ExtAuth guard in
tokenAllowed, switches its comparison to case-insensitive to match
the user lookup's own COLLATE NOCASE semantics, and restores Subsonic
JWT test config after each spec instead of leaking SessionTimeout.

* feat(request): add a token epoch holder for handler-to-middleware signalling

* refactor(server): write the refreshed JWT header after the handler runs

* feat(auth): revoke all tokens for a user when their password changes

* fix(server): restore Unwrap on the JWT refresh writer so SSE write deadlines apply

* test(auth): pin that non-session tokens reject API access tokens

* test(jellyfin): pin token scoping and epoch revocation end to end

Exercises auth.CreateAPIToken and CheckClaims against the real Jellyfin
router and SQLite DB: the minted token has no exp and is aud-scoped to
jellyfin, and bumping token_epoch through the real UserRepository revokes
an already-issued token on the next protected request.

* test(nativeapi): pin the token-epoch handoff through a real password-change request

Drive a self password change through the real Authenticator/JWTRefresher
chain and a real SQLite-backed userRepository, so the epoch handoff between
Put and the refreshed-token writer is verified end to end, not as two
separately-tested halves. Also fix tokenAllowed to read the enriched ctx it
was given instead of r.Context(), so its warning log carries the username.

* refactor(server): drop tokenAllowed's now-unused request parameter

Finding-2 already moved every use to ctx; r was dead weight. Also note
in the new nativeapi test why it must stay the package's only real-DB
spec: db.Db() is a process-wide singleton its cleanup closes for good.

* refactor(auth): remove duplication in claim decoding and token minting

* refactor(auth): group aud with the standard JWT claims

* refactor(auth): read aud with the standard-claim accessor pattern

* fix(log): redact every api_key spelling the Jellyfin API accepts

* fix(auth): bind session tokens to the user id, not just the username

* fix(auth): return the token epoch from the same atomic increment

* fix(auth): bump the token epoch in the same statement as the password write

* chore(auth): trim comments to the why-only budget
2026-08-22 20:36:24 -04:00

158 lines
5.1 KiB
Go

package e2e
import (
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/server/jellyfin/dto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Authentication", func() {
BeforeEach(func() { setupTestDB() })
authenticate := func(username, pw string) *httptest.ResponseRecorder {
body := `{"Username":"` + username + `","Pw":"` + pw + `"}`
return rawReq("POST", "/Users/AuthenticateByName", body)
}
Describe("POST /Users/AuthenticateByName", func() {
It("authenticates a valid user and returns a usable token", func() {
w := authenticate("admin", "password")
var res dto.AuthenticationResult
parseInto(w, &res)
Expect(res.AccessToken).ToNot(BeEmpty())
Expect(res.User).ToNot(BeNil())
Expect(res.User.Name).To(Equal("admin"))
Expect(res.User.Id).To(Equal(enc(testID("admin-1"))))
Expect(res.User.Policy.IsAdministrator).To(BeTrue())
Expect(res.ServerId).To(MatchRegexp("^[0-9a-f]{32}$"))
// The returned token must actually authenticate a protected request.
r := httptest.NewRequest("GET", "/Users/Me", nil)
r.Header.Set("X-Emby-Token", res.AccessToken)
pw := httptest.NewRecorder()
router.ServeHTTP(pw, r)
Expect(pw.Code).To(Equal(http.StatusOK))
})
It("marks a non-admin user's policy as non-administrator", func() {
w := authenticate("regular", "password")
var res dto.AuthenticationResult
parseInto(w, &res)
Expect(res.User.Policy.IsAdministrator).To(BeFalse())
})
It("rejects a wrong password", func() {
Expect(authenticate("admin", "wrong").Code).To(Equal(http.StatusUnauthorized))
})
It("rejects an empty password", func() {
Expect(authenticate("admin", "").Code).To(Equal(http.StatusUnauthorized))
})
It("rejects an unknown user", func() {
Expect(authenticate("nobody", "password").Code).To(Equal(http.StatusUnauthorized))
})
It("rejects a malformed body", func() {
Expect(rawReq("POST", "/Users/AuthenticateByName", "not json").Code).To(Equal(http.StatusBadRequest))
})
It("mints a non-expiring token scoped to the Jellyfin audience", func() {
w := authenticate("admin", "password")
var res dto.AuthenticationResult
parseInto(w, &res)
claims, err := auth.Validate(res.AccessToken)
Expect(err).ToNot(HaveOccurred())
Expect(claims.ExpiresAt.IsZero()).To(BeTrue())
Expect(claims.Audience).To(Equal([]string{"jellyfin"}))
Expect(claims.Subject).To(Equal("admin"))
})
It("revokes an already-issued token when the user's epoch is bumped", func() {
w := authenticate("admin", "password")
var res dto.AuthenticationResult
parseInto(w, &res)
r := httptest.NewRequest("GET", "/Users/Me", nil)
r.Header.Set("X-Emby-Token", res.AccessToken)
pw := httptest.NewRecorder()
router.ServeHTTP(pw, r)
Expect(pw.Code).To(Equal(http.StatusOK))
// A real password change through the repository, which is what revokes in production.
admin, err := ds.User(ctx).Get(testID("admin-1"))
Expect(err).ToNot(HaveOccurred())
admin.NewPassword = "rotated"
Expect(ds.User(ctx).Put(admin)).To(Succeed())
r = httptest.NewRequest("GET", "/Users/Me", nil)
r.Header.Set("X-Emby-Token", res.AccessToken)
pw = httptest.NewRecorder()
router.ServeHTTP(pw, r)
Expect(pw.Code).To(Equal(http.StatusUnauthorized))
})
})
Describe("GET /Users/Public", func() {
publicUsers := func() []dto.UserDto {
w := rawReq("GET", "/Users/Public", "")
Expect(w.Code).To(Equal(http.StatusOK))
var users []dto.UserDto
parseInto(w, &users)
return users
}
It("returns an empty list when no users are exposed", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Jellyfin.ExposedPublicUsers = ""
Expect(publicUsers()).To(BeEmpty())
})
It("lists the configured users to an unauthenticated caller, without policy", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Jellyfin.ExposedPublicUsers = "regular"
users := publicUsers()
Expect(users).To(HaveLen(1))
Expect(users[0].Name).To(Equal("regular"))
Expect(users[0].Id).To(Equal(enc(testID("regular-1"))))
Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login
})
})
Describe("current user", func() {
It("returns the caller from GET /Users/Me", func() {
var u dto.UserDto
parseInto(getAs(regularUser, "/Users/Me"), &u)
Expect(u.Name).To(Equal("regular"))
Expect(u.Id).To(Equal(enc(testID("regular-1"))))
})
It("returns the caller from GET /Users/{userId}", func() {
var u dto.UserDto
parseInto(get("/Users/admin-1"), &u)
Expect(u.Name).To(Equal("admin"))
})
})
Describe("auth enforcement", func() {
It("rejects a protected request with no token", func() {
Expect(rawReq("GET", "/Users/Me", "").Code).To(Equal(http.StatusUnauthorized))
})
It("rejects a protected request with a bogus token", func() {
r := httptest.NewRequest("GET", "/Users/Me", nil)
r.Header.Set("X-Emby-Token", "not-a-valid-jwt")
w := httptest.NewRecorder()
router.ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})