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
This commit is contained in:
Deluan Quintão 2026-08-22 20:36:24 -04:00 committed by GitHub
parent 295886cb9a
commit 59810c3d59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 971 additions and 46 deletions

View File

@ -214,5 +214,14 @@ var _ = Describe("auth_router", func() {
_, err = verifyLinkToken(nonExpiringToken)
Expect(err).To(MatchError("link token missing expiration"))
})
It("rejects a Jellyfin access token", func() {
usr := &model.User{ID: "u1", UserName: "johndoe"}
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
_, err = verifyLinkToken(tokenStr)
Expect(err).To(HaveOccurred())
})
})
})

View File

@ -4,6 +4,8 @@ import (
"cmp"
"context"
"crypto/sha256"
"errors"
"slices"
"sync"
"time"
@ -26,6 +28,13 @@ var (
PublicTokenAuth *jwtauth.JWTAuth
)
// Audiences a session token can be scoped to. A token with no audience is accepted anywhere.
const (
AudienceJellyfin = "jellyfin"
AudienceSubsonic = "subsonic"
AudienceNative = "native"
)
// Init creates the JWTAuth objects from the secrets stored in the DB.
// Missing or undecryptable secrets are regenerated and stored.
func Init(ds model.DataStore) {
@ -66,15 +75,20 @@ func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) {
return token, err
}
func CreateToken(u *model.User) (string, error) {
claims := Claims{
func userClaims(u *model.User, audience []string) Claims {
return Claims{
Issuer: consts.JWTIssuer,
Subject: u.UserName,
IssuedAt: time.Now(),
UserID: u.ID,
IsAdmin: u.IsAdmin,
Epoch: u.TokenEpoch,
Audience: audience,
}
token, _, err := TokenAuth.Encode(claims.ToMap())
}
func CreateToken(u *model.User) (string, error) {
token, _, err := TokenAuth.Encode(userClaims(u, nil).ToMap())
if err != nil {
return "", err
}
@ -82,10 +96,20 @@ func CreateToken(u *model.User) (string, error) {
return TouchToken(token)
}
// CreateAPIToken mints a non-expiring token scoped to one API, matching how Jellyfin
// clients expect tokens to behave. Revocation is by token epoch, not expiry.
func CreateAPIToken(u *model.User, audience string) (string, error) {
_, token, err := TokenAuth.Encode(userClaims(u, []string{audience}).ToMap())
return token, err
}
func TouchToken(token jwt.Token) (string, error) {
claims := ClaimsFromToken(token).
WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout))
_, newToken, err := TokenAuth.Encode(claims.ToMap())
return TouchClaims(ClaimsFromToken(token))
}
func TouchClaims(c Claims) (string, error) {
c = c.WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout))
_, newToken, err := TokenAuth.Encode(c.ToMap())
return newToken, err
}
@ -106,6 +130,29 @@ func ValidatePublic(tokenStr string) (Claims, error) {
return ClaimsFromToken(token), nil
}
var (
ErrTokenRevoked = errors.New("token revoked")
ErrWrongAudience = errors.New("token not valid for this API")
ErrWrongUser = errors.New("token issued for a different user")
)
// CheckClaims gates a session token against the user it names. Callers must have already
// verified the signature; this adds revocation and API scoping on top.
func CheckClaims(c Claims, usr model.User, audience string) error {
// Usernames can be reused: deleting a user and recreating the name yields a new random id
// at epoch 0, which an old token would otherwise match.
if c.UserID != "" && c.UserID != usr.ID {
return ErrWrongUser
}
if c.Epoch != usr.TokenEpoch {
return ErrTokenRevoked
}
if len(c.Audience) > 0 && !slices.Contains(c.Audience, audience) {
return ErrWrongAudience
}
return nil
}
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
u, err := ds.User(ctx).FindFirstAdmin()
if err != nil {

View File

@ -151,4 +151,113 @@ var _ = Describe("Auth", func() {
Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay))
})
})
Describe("CreateAPIToken", func() {
var usr *model.User
BeforeEach(func() {
usr = &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 4}
})
It("does not expire", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
claims, err := auth.Validate(tokenStr)
Expect(err).ToNot(HaveOccurred())
Expect(claims.ExpiresAt.IsZero()).To(BeTrue())
})
It("carries the audience and the user's epoch", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
claims, err := auth.Validate(tokenStr)
Expect(err).ToNot(HaveOccurred())
Expect(claims.Audience).To(Equal([]string{"jellyfin"}))
Expect(claims.Epoch).To(Equal(4))
Expect(claims.Subject).To(Equal("johndoe"))
Expect(claims.UserID).To(Equal("123"))
})
})
Describe("CreateToken with an epoch", func() {
It("carries the epoch and still expires", func() {
usr := &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 9}
tokenStr, err := auth.CreateToken(usr)
Expect(err).ToNot(HaveOccurred())
claims, err := auth.Validate(tokenStr)
Expect(err).ToNot(HaveOccurred())
Expect(claims.Epoch).To(Equal(9))
Expect(claims.Audience).To(BeEmpty())
Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now()))
})
})
Describe("TouchClaims", func() {
It("preserves custom claims and refreshes the expiry", func() {
tokenStr, err := auth.TouchClaims(auth.Claims{Subject: "johndoe", UserID: "123", Epoch: 5})
Expect(err).ToNot(HaveOccurred())
claims, err := auth.Validate(tokenStr)
Expect(err).ToNot(HaveOccurred())
Expect(claims.Epoch).To(Equal(5))
Expect(claims.Subject).To(Equal("johndoe"))
Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now()))
})
})
Describe("CheckClaims", func() {
usr := model.User{ID: "123", UserName: "johndoe", TokenEpoch: 2}
It("accepts a matching epoch and audience", func() {
c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}}
Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed())
})
It("accepts a token with no audience on any API", func() {
c := auth.Claims{Epoch: 2}
Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed())
Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed())
Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(Succeed())
})
It("rejects a stale epoch", func() {
c := auth.Claims{Epoch: 1, Audience: []string{auth.AudienceJellyfin}}
Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(MatchError(auth.ErrTokenRevoked))
})
It("rejects a token minted for another API", func() {
c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}}
Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(MatchError(auth.ErrWrongAudience))
Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(MatchError(auth.ErrWrongAudience))
})
It("accepts a multi-audience token that includes this API", func() {
c := auth.Claims{Epoch: 2, Audience: []string{"other", auth.AudienceNative}}
Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed())
})
It("accepts a pre-upgrade token against a never-bumped user", func() {
fresh := model.User{ID: "456", UserName: "newbie"}
Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed())
})
It("accepts a token whose user id matches", func() {
c := auth.Claims{UserID: "123", Epoch: 2}
Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed())
})
It("rejects a token for a deleted user recreated under the same name", func() {
recreated := model.User{ID: "new-random-id", UserName: "johndoe"}
c := auth.Claims{UserID: "123", Audience: []string{auth.AudienceJellyfin}}
Expect(auth.CheckClaims(c, recreated, auth.AudienceJellyfin)).To(MatchError(auth.ErrWrongUser))
})
It("accepts a token that carries no user id", func() {
fresh := model.User{ID: "456", UserName: "newbie"}
Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed())
})
})
})

View File

@ -11,7 +11,8 @@ import (
type Claims struct {
// Standard JWT claims
Issuer string
Subject string // username for session tokens
Subject string // username for session tokens
Audience []string // which API may accept this token; empty means any
IssuedAt time.Time
ExpiresAt time.Time
@ -22,6 +23,7 @@ type Claims struct {
Format string // "f" - audio format
BitRate int // "b" - audio bitrate
ShareID string // "sid" - share ID for share stream tokens
Epoch int // "ep" - the user's token_epoch at mint time
}
// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode().
@ -34,6 +36,9 @@ func (c Claims) ToMap() map[string]any {
if c.Subject != "" {
m[jwt.SubjectKey] = c.Subject
}
if len(c.Audience) > 0 {
m[jwt.AudienceKey] = c.Audience
}
if !c.IssuedAt.IsZero() {
m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix()
}
@ -58,6 +63,9 @@ func (c Claims) ToMap() map[string]any {
if c.ShareID != "" {
m["sid"] = c.ShareID
}
if c.Epoch != 0 {
m["ep"] = c.Epoch
}
return m
}
@ -73,6 +81,7 @@ func ClaimsFromToken(token jwt.Token) Claims {
c.Subject, _ = token.Subject()
c.IssuedAt, _ = token.IssuedAt()
c.ExpiresAt, _ = token.Expiration()
c.Audience, _ = token.Audience()
var uid string
if err := token.Get("uid", &uid); err == nil {
@ -90,15 +99,24 @@ func ClaimsFromToken(token jwt.Token) Claims {
if err := token.Get("f", &f); err == nil {
c.Format = f
}
if err := token.Get("b", &c.BitRate); err != nil {
var bf float64
if err := token.Get("b", &bf); err == nil {
c.BitRate = int(bf)
}
}
c.BitRate = intClaim(token, "b")
var sid string
if err := token.Get("sid", &sid); err == nil {
c.ShareID = sid
}
c.Epoch = intClaim(token, "ep")
return c
}
// intClaim reads a numeric claim, which a parsed token may decode as either int or float64.
func intClaim(token jwt.Token, key string) int {
var i int
if err := token.Get(key, &i); err == nil {
return i
}
var f float64
if err := token.Get(key, &f); err == nil {
return int(f)
}
return 0
}

View File

@ -105,4 +105,44 @@ var _ = Describe("Claims", func() {
})
})
Describe("Audience and Epoch claims", func() {
It("omits both when zero", func() {
m := auth.Claims{ID: "artwork-id"}.ToMap()
Expect(m).ToNot(HaveKey("aud"))
Expect(m).ToNot(HaveKey("ep"))
})
It("includes them when set", func() {
m := auth.Claims{Subject: "u", Epoch: 3, Audience: []string{"jellyfin"}}.ToMap()
Expect(m).To(HaveKeyWithValue("ep", 3))
Expect(m).To(HaveKeyWithValue("aud", []string{"jellyfin"}))
})
It("round-trips through a signed token", func() {
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
_, tokenStr, err := tokenAuth.Encode(auth.Claims{
Subject: "u", Epoch: 7, Audience: []string{"jellyfin"},
}.ToMap())
Expect(err).ToNot(HaveOccurred())
token, err := jwtauth.VerifyToken(tokenAuth, tokenStr)
Expect(err).ToNot(HaveOccurred())
claims := auth.ClaimsFromToken(token)
Expect(claims.Epoch).To(Equal(7))
Expect(claims.Audience).To(Equal([]string{"jellyfin"}))
})
It("reads a token that has neither claim", func() {
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
_, tokenStr, err := tokenAuth.Encode(auth.Claims{Subject: "u"}.ToMap())
Expect(err).ToNot(HaveOccurred())
token, err := jwtauth.VerifyToken(tokenAuth, tokenStr)
Expect(err).ToNot(HaveOccurred())
claims := auth.ClaimsFromToken(token)
Expect(claims.Epoch).To(BeZero())
Expect(claims.Audience).To(BeEmpty())
})
})
})

View File

@ -232,6 +232,16 @@ var _ = Describe("Token", func() {
_, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
Expect(err).To(MatchError(ErrTokenStale))
})
It("rejects a Jellyfin access token", func() {
mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime}
usr := &model.User{ID: "u1", UserName: "johndoe"}
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
_, err = svc.ResolveRequestFromToken(ctx, tokenStr, mf, 0)
Expect(err).To(MatchError(ErrTokenInvalid))
})
})
Describe("paramsFromToken", func() {

View File

@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE user ADD COLUMN token_epoch INTEGER NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE user DROP COLUMN token_epoch;

View File

@ -47,8 +47,9 @@ var redacted = &Hook{
// External services query params. Values can be JWTs (dots, dashes), so match everything up
// to the next query separator or whitespace, not just word chars. A [\w]+ class would stop
// at a JWT's first '.' and leak its payload and signature.
"([^\\w]api_key=)[^&\\s]+",
// at a JWT's first '.' and leak its payload and signature. Case-insensitive with an
// optional underscore: the API accepts api_key, apikey and ApiKey alike.
"(?i)([^\\w]api_?key=)[^&\\s]+",
},
}

View File

@ -264,5 +264,16 @@ var _ = Describe("Logger", func() {
msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1"
Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1"))
})
DescribeTable("redacts every api_key spelling the Jellyfin API accepts",
func(param string) {
msg := "/jellyfin/Audio/abc/File?" + param + "=SECRET&other=1"
Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/File?" + param + "=[REDACTED]&other=1"))
},
Entry("api_key", "api_key"),
Entry("apikey", "apikey"),
Entry("ApiKey", "ApiKey"),
Entry("APIKEY", "APIKEY"),
)
})
})

View File

@ -2,6 +2,7 @@ package request
import (
"context"
"sync/atomic"
"github.com/navidrome/navidrome/model"
)
@ -9,15 +10,16 @@ import (
type contextKey string
const (
User = contextKey("user")
Username = contextKey("username")
Client = contextKey("client")
Version = contextKey("version")
Player = contextKey("player")
Transcoding = contextKey("transcoding")
ClientUniqueId = contextKey("clientUniqueId")
ReverseProxyIp = contextKey("reverseProxyIp")
InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins
User = contextKey("user")
Username = contextKey("username")
Client = contextKey("client")
Version = contextKey("version")
Player = contextKey("player")
Transcoding = contextKey("transcoding")
ClientUniqueId = contextKey("clientUniqueId")
ReverseProxyIp = contextKey("reverseProxyIp")
InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins
TokenEpochHolder = contextKey("tokenEpochHolder")
)
var allKeys = []contextKey{
@ -125,3 +127,32 @@ func AddValues(ctx, requestCtx context.Context) context.Context {
}
return ctx
}
type tokenEpochHolder struct {
value atomic.Int64
}
// WithTokenEpochHolder installs a slot a handler can use to report a bumped token epoch
// back to middleware that has already returned from the handler's perspective.
func WithTokenEpochHolder(ctx context.Context) context.Context {
h := &tokenEpochHolder{}
h.value.Store(-1)
return context.WithValue(ctx, TokenEpochHolder, h)
}
func SetTokenEpoch(ctx context.Context, epoch int) {
if h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder); ok {
h.value.Store(int64(epoch))
}
}
func TokenEpochFrom(ctx context.Context) (int, bool) {
h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder)
if !ok {
return 0, false
}
if v := h.value.Load(); v >= 0 {
return int(v), true
}
return 0, false
}

View File

@ -0,0 +1,17 @@
package request
import (
"testing"
"github.com/navidrome/navidrome/log"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// tests.Init is not used here: the tests package imports model/request, so importing it
// back would create an import cycle.
func TestRequest(t *testing.T) {
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Request Suite")
}

View File

@ -0,0 +1,40 @@
package request
import (
"context"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Token epoch holder", func() {
It("reports nothing when unset", func() {
ctx := WithTokenEpochHolder(context.TODO())
_, ok := TokenEpochFrom(ctx)
Expect(ok).To(BeFalse())
})
It("round-trips a value set by the handler", func() {
ctx := WithTokenEpochHolder(context.TODO())
SetTokenEpoch(ctx, 7)
epoch, ok := TokenEpochFrom(ctx)
Expect(ok).To(BeTrue())
Expect(epoch).To(Equal(7))
})
It("survives being wrapped in a derived context", func() {
ctx := WithTokenEpochHolder(context.TODO())
SetTokenEpoch(context.WithValue(ctx, contextKey("unrelated"), 1), 3)
epoch, ok := TokenEpochFrom(ctx)
Expect(ok).To(BeTrue())
Expect(epoch).To(Equal(3))
})
It("is a no-op with no holder installed", func() {
Expect(func() { SetTokenEpoch(context.TODO(), 5) }).ToNot(Panic())
_, ok := TokenEpochFrom(context.TODO())
Expect(ok).To(BeFalse())
})
})

View File

@ -22,6 +22,8 @@ type User struct {
// This is only available on the backend, and it is never sent over the wire
Password string `structs:"-" json:"-"`
// Bumped on password change to invalidate every issued token for this user.
TokenEpoch int `structs:"-" json:"-"`
// This is used to set or change a password when calling Put. If it is empty, the password is not changed.
// It is received from the UI with the name "password"
NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec

View File

@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
@ -126,14 +127,30 @@ func (r *userRepository) Put(u *model.User) error {
}
delete(values, "current_password")
// Save/update the user
// The epoch bump rides the password UPDATE: as two statements they can interleave with a
// concurrent change and leave a session valid that the other change should have revoked.
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
count, err := r.executeSQL(update)
if err != nil {
return err
var isNewUser bool
var epoch int
if u.NewPassword != "" {
var res struct{ TokenEpoch int }
err = r.queryOne(update.Set("token_epoch", Expr("token_epoch + 1")).
Suffix("RETURNING token_epoch"), &res)
switch {
case errors.Is(err, model.ErrNotFound):
isNewUser = true
case err != nil:
return err
default:
epoch = res.TokenEpoch
}
} else {
count, err := r.executeSQL(update)
if err != nil {
return err
}
isNewUser = count == 0
}
isNewUser := count == 0
if isNewUser {
values["created_at"] = time.Now()
insert := Insert(r.tableName).SetMap(values)
@ -163,6 +180,12 @@ func (r *userRepository) Put(u *model.User) error {
}
}
// Only the caller's own token can be refreshed in-flight; an admin resetting another
// user must keep their own epoch.
if u.NewPassword != "" && !isNewUser && loggedUser(r.ctx).ID == u.ID {
request.SetTokenEpoch(r.ctx, epoch)
}
return nil
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"slices"
"sync"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
@ -13,6 +14,7 @@ import (
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -683,4 +685,159 @@ var _ = Describe("UserRepository", func() {
Expect(query).To(ContainSubstring("user.id = {:p0}"))
})
})
Describe("token epoch", func() {
var repo model.UserRepository
var usr model.User
newUser := func() model.User {
uid := id.NewRandom()
// user_name is unique; suffix it so each It gets its own row in the shared suite DB.
return model.User{ID: uid, UserName: "epoch-user-" + uid, Name: "Epoch", NewPassword: "hunter2"}
}
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true})
repo = NewUserRepository(ctx, GetDBXBuilder())
usr = newUser()
Expect(repo.Put(&usr)).To(Succeed())
})
It("starts at zero for a new user", func() {
got, err := repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(0))
})
It("increments once per password change", func() {
usr.NewPassword = "second"
Expect(repo.Put(&usr)).To(Succeed())
got, err := repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(1))
usr.NewPassword = "third"
Expect(repo.Put(&usr)).To(Succeed())
got, err = repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(2))
})
It("leaves the epoch alone when the password is untouched", func() {
usr.NewPassword = ""
usr.Name = "Renamed"
Expect(repo.Put(&usr)).To(Succeed())
got, err := repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(0))
Expect(got.Name).To(Equal("Renamed"))
})
It("never signals the same epoch to two concurrent password changes", func() {
// Each writer's epoch must be the one its own UPDATE produced.
const callers = 4
var mu sync.Mutex
var signalled []int
var wg sync.WaitGroup
for range callers {
wg.Go(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: usr.ID})
ctx = request.WithTokenEpochHolder(ctx)
own := NewUserRepository(ctx, GetDBXBuilder())
u := usr
u.NewPassword = "concurrent"
if err := own.Put(&u); err != nil {
return // the shared in-memory test DB can raise SQLITE_LOCKED
}
epoch, ok := request.TokenEpochFrom(ctx)
if !ok {
return
}
mu.Lock()
defer mu.Unlock()
signalled = append(signalled, epoch)
})
}
wg.Wait()
Expect(signalled).To(HaveLen(len(slice.Unique(signalled))),
"an epoch was signalled to more than one writer: %v", signalled)
})
})
Describe("Put and the token epoch", func() {
newRepo := func(actingUserID string) model.UserRepository {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: actingUserID, IsAdmin: true})
ctx = request.WithTokenEpochHolder(ctx)
return NewUserRepository(ctx, GetDBXBuilder())
}
It("does not bump when creating a user", func() {
repo := newRepo("admin")
usr := model.User{ID: id.NewRandom(), UserName: "fresh", NewPassword: "pw1"}
Expect(repo.Put(&usr)).To(Succeed())
got, err := repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(0))
})
It("bumps when the password changes", func() {
repo := newRepo("admin")
usr := model.User{ID: id.NewRandom(), UserName: "changer", NewPassword: "pw1"}
Expect(repo.Put(&usr)).To(Succeed())
usr.NewPassword = "pw2"
Expect(repo.Put(&usr)).To(Succeed())
got, err := repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(1))
})
It("does not bump on an edit that leaves the password alone", func() {
repo := newRepo("admin")
usr := model.User{ID: id.NewRandom(), UserName: "renamer", NewPassword: "pw1"}
Expect(repo.Put(&usr)).To(Succeed())
usr.NewPassword = ""
usr.Name = "New Display Name"
Expect(repo.Put(&usr)).To(Succeed())
got, err := repo.Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.TokenEpoch).To(Equal(0))
})
It("signals the new epoch when a user changes their own password", func() {
userID := id.NewRandom()
repo := newRepo(userID)
usr := model.User{ID: userID, UserName: "self", NewPassword: "pw1"}
Expect(repo.Put(&usr)).To(Succeed())
usr.NewPassword = "pw2"
Expect(repo.Put(&usr)).To(Succeed())
epoch, ok := request.TokenEpochFrom(repo.(*userRepository).ctx)
Expect(ok).To(BeTrue())
Expect(epoch).To(Equal(1))
})
It("does not signal when an admin changes someone else's password", func() {
repo := newRepo("some-admin")
usr := model.User{ID: id.NewRandom(), UserName: "other", NewPassword: "pw1"}
Expect(repo.Put(&usr)).To(Succeed())
usr.NewPassword = "pw2"
Expect(repo.Put(&usr)).To(Succeed())
_, ok := request.TokenEpochFrom(repo.(*userRepository).ctx)
Expect(ok).To(BeFalse())
})
})
})

View File

@ -12,10 +12,12 @@ import (
"net/http"
"slices"
"strings"
"sync"
"time"
"github.com/deluan/rest"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
@ -260,7 +262,7 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader)
if err != nil {
if err != nil || !tokenAllowed(ctx) {
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
}
@ -270,24 +272,88 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
}
}
// JWTRefresher updates the expiry date of the received JWT token, and add the new one to the Authorization Header
// tokenAllowed re-checks a JWT that actually identifies the resolved user. Header and
// config auth carry no token, so they short-circuit to true.
func tokenAllowed(ctx context.Context) bool {
token, _, err := jwtauth.FromContext(ctx)
if err != nil || token == nil {
return true
}
usr, ok := request.UserFrom(ctx)
if !ok {
return true
}
claims := auth.ClaimsFromToken(token)
if !strings.EqualFold(claims.Subject, usr.UserName) {
return true
}
if err := auth.CheckClaims(claims, usr, auth.AudienceNative); err != nil {
log.Warn(ctx, "Native API: rejected token", "user", claims.Subject, err)
return false
}
return true
}
// refreshingWriter defers the refreshed-token header until the handler's first write, so an
// epoch the handler bumped reaches the token the client stores.
type refreshingWriter struct {
http.ResponseWriter
ctx context.Context
token jwt.Token
once sync.Once
}
func (w *refreshingWriter) setToken() {
w.once.Do(func() {
claims := auth.ClaimsFromToken(w.token)
if epoch, ok := request.TokenEpochFrom(w.ctx); ok {
claims.Epoch = epoch
}
newToken, err := auth.TouchClaims(claims)
if err != nil {
log.Error(w.ctx, "Could not sign new token", err)
return
}
w.Header().Set(consts.UIAuthorizationHeader, newToken)
})
}
func (w *refreshingWriter) WriteHeader(code int) {
w.setToken()
w.ResponseWriter.WriteHeader(code)
}
func (w *refreshingWriter) Write(b []byte) (int, error) {
w.setToken()
return w.ResponseWriter.Write(b)
}
// Flush keeps the SSE events route working through the wrap.
func (w *refreshingWriter) Flush() {
w.setToken()
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Unwrap lets capability lookups, such as SSE's write deadline, see past this wrap.
func (w *refreshingWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
// JWTRefresher updates the expiry date of the received JWT token, and adds the new one to
// the Authorization Header.
func JWTRefresher(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
token, _, err := jwtauth.FromContext(ctx)
if err != nil {
token, _, err := jwtauth.FromContext(r.Context())
if err != nil || token == nil {
next.ServeHTTP(w, r)
return
}
newTokenString, err := auth.TouchToken(token)
if err != nil {
log.Error(r, "Could not sign new token", err)
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
}
w.Header().Set(consts.UIAuthorizationHeader, newTokenString)
next.ServeHTTP(w, r)
ctx := request.WithTokenEpochHolder(r.Context())
rw := &refreshingWriter{ResponseWriter: w, ctx: ctx, token: token}
next.ServeHTTP(rw, r.WithContext(ctx))
rw.setToken()
})
}

View File

@ -12,6 +12,7 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
@ -342,4 +343,138 @@ var _ = Describe("Auth", func() {
Expect(u.IsAdmin).To(BeFalse())
})
})
Describe("Authenticator token gating", func() {
var ds *tests.MockDataStore
var usr *model.User
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Hour
ds = &tests.MockDataStore{}
auth.Init(ds)
ur := ds.User(context.TODO()).(*tests.MockedUserRepo)
usr = &model.User{ID: "u1", UserName: "johndoe", NewPassword: "pw", TokenEpoch: 2}
Expect(ur.Put(usr)).To(Succeed())
})
serve := func(token string) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", "/api/song", nil)
r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
w := httptest.NewRecorder()
handler := JWTVerifier(Authenticator(ds)(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) },
)))
handler.ServeHTTP(w, r)
return w
}
It("accepts a current session token", func() {
tokenStr, err := auth.CreateToken(usr)
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
})
It("rejects a jellyfin-scoped token", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
})
It("rejects a token with a stale epoch", func() {
tokenStr, err := auth.CreateToken(usr)
Expect(err).ToNot(HaveOccurred())
usr.TokenEpoch = 3
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
})
It("ignores a stray token for someone else when config auto-login resolves the user", func() {
conf.Server.DevAutoLoginUsername = usr.UserName
tokenStr, err := auth.CreateToken(&model.User{UserName: "someone-else"})
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
})
It("rejects a stale-epoch token whose subject differs only in case from the resolved user", func() {
tokenStr, err := auth.CreateToken(&model.User{UserName: strings.ToUpper(usr.UserName), TokenEpoch: usr.TokenEpoch})
Expect(err).ToNot(HaveOccurred())
usr.TokenEpoch = 5
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
})
})
Describe("JWTRefresher", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// TouchClaims reads this; left at zero every refreshed token is born expired.
conf.Server.SessionTimeout = time.Hour
auth.Init(&tests.MockDataStore{})
})
serveWith := func(handler http.HandlerFunc) *httptest.ResponseRecorder {
usr := model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1}
tokenStr, err := auth.CreateToken(&usr)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest("GET", "/api/song", nil)
r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+tokenStr)
w := httptest.NewRecorder()
JWTVerifier(JWTRefresher(handler)).ServeHTTP(w, r)
return w
}
It("writes a refreshed token when the handler writes a body", func() {
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
})
Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty())
})
It("writes a refreshed token when the handler writes no body", func() {
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty())
})
It("picks up an epoch the handler reported", func() {
w := serveWith(func(w http.ResponseWriter, r *http.Request) {
request.SetTokenEpoch(r.Context(), 42)
w.WriteHeader(http.StatusOK)
})
claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader))
Expect(err).ToNot(HaveOccurred())
Expect(claims.Epoch).To(Equal(42))
})
It("keeps the original epoch when the handler reports nothing", func() {
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader))
Expect(err).ToNot(HaveOccurred())
Expect(claims.Epoch).To(Equal(1))
})
It("propagates Flush to the underlying ResponseWriter", func() {
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
})
Expect(w.Flushed).To(BeTrue())
})
It("exposes the underlying ResponseWriter via Unwrap, for http.ResponseController lookups", func() {
var unwrapped http.ResponseWriter
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
u, ok := w.(interface{ Unwrap() http.ResponseWriter })
Expect(ok).To(BeTrue())
unwrapped = u.Unwrap()
w.WriteHeader(http.StatusOK)
})
Expect(unwrapped).To(BeIdenticalTo(w))
})
})
})

View File

@ -58,6 +58,8 @@ query param — all forms are accepted, matching what different clients do).
`/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force
surface.
Access tokens do not expire, matching real Jellyfin. They are revoked by a password change, which bumps the user's token epoch.
### Public user list (login picker)
`GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the

View File

@ -36,7 +36,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) {
log.Error(ctx, "Jellyfin API: could not update last login date", "username", body.Username, err)
}
token, err := auth.CreateToken(usr)
token, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
if err != nil {
api.internalError(w, r, err)
return

View File

@ -6,6 +6,7 @@ import (
"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"
@ -61,6 +62,42 @@ var _ = Describe("Authentication", func() {
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() {

View File

@ -167,6 +167,10 @@ func (api *Router) userFromToken(r *http.Request) (model.User, bool) {
log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err)
return model.User{}, false
}
if err := auth.CheckClaims(claims, *usr, auth.AudienceJellyfin); err != nil {
log.Warn(r.Context(), "Jellyfin API: rejected token", "user", claims.Subject, err)
return model.User{}, false
}
return *usr, true
}

View File

@ -95,6 +95,52 @@ var _ = Describe("authenticate middleware", func() {
api.authenticate(next).ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
Context("token scoping and revocation", func() {
var usr *model.User
BeforeEach(func() {
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
usr = &model.User{ID: testID("u2"), UserName: "bob", NewPassword: "secret", TokenEpoch: 3}
Expect(ur.Put(usr)).To(Succeed())
})
serve := func(token string) *httptest.ResponseRecorder {
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items", nil)
r.Header.Set("X-Emby-Token", token)
api.authenticate(next).ServeHTTP(w, r)
return w
}
It("accepts a jellyfin-scoped token with the current epoch", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
})
It("rejects a token whose epoch is stale", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
usr.TokenEpoch = 4
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
})
It("rejects a token minted for another API", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceNative)
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
})
It("still accepts an unscoped session token", func() {
tokenStr, err := auth.CreateToken(usr)
Expect(err).ToNot(HaveOccurred())
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
})
})
})
var _ = Describe("withPlayer middleware", func() {

View File

@ -0,0 +1,80 @@
package nativeapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type noopPluginUnloader struct{}
func (noopPluginUnloader) UnloadDisabledPlugins(context.Context) {}
// Pins that the token-epoch handoff survives a real request through the real middleware chain.
var _ = Describe("PUT /user/{id}: token refresh on self password change", func() {
var ds model.DataStore
var router http.Handler
BeforeEach(func() {
// db.Db() is a process-wide singleton that this DeferCleanup closes for the whole binary; keep this the only real-DB spec in this package.
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableUserEditing = true
conf.Server.EnableSharing = false
conf.Server.SessionTimeout = time.Hour
conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "nativeapi-user-refresh.db") + "?_journal_mode=WAL"
DeferCleanup(db.Init(GinkgoT().Context()))
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
auth.Init(ds)
userService := core.NewUser(ds, noopPluginUnloader{})
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
})
It("carries the bumped epoch in the refreshed token, not the epoch the token was minted with", func() {
usr := model.User{UserName: "selfchanger", Name: "Self Changer", NewPassword: "old-password"}
Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed())
token, err := auth.CreateToken(&usr)
Expect(err).ToNot(HaveOccurred())
body, _ := json.Marshal(map[string]any{
"userName": usr.UserName,
"name": usr.Name,
"currentPassword": "old-password",
"password": "new-password",
})
req := createAuthenticatedRequest(http.MethodPut, "/user/"+usr.ID, bytes.NewBuffer(body), token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
refreshed := w.Header().Get(consts.UIAuthorizationHeader)
Expect(refreshed).ToNot(BeEmpty())
claims, err := auth.Validate(refreshed)
Expect(err).ToNot(HaveOccurred())
reloaded, err := ds.User(GinkgoT().Context()).Get(usr.ID)
Expect(err).ToNot(HaveOccurred())
Expect(reloaded.TokenEpoch).To(Equal(1))
Expect(claims.Epoch).To(Equal(reloaded.TokenEpoch))
})
})

View File

@ -178,7 +178,9 @@ func validateCredentials(user *model.User, pass, token, salt, jwt string) error
switch {
case jwt != "":
claims, err := auth.Validate(jwt)
valid = err == nil && claims.Subject == user.UserName
valid = err == nil &&
claims.Subject == user.UserName &&
auth.CheckClaims(claims, *user, auth.AudienceSubsonic) == nil
case pass != "":
if strings.HasPrefix(pass, "enc:") {
if dec, err := hex.DecodeString(pass[4:]); err == nil {

View File

@ -470,6 +470,7 @@ var _ = Describe("Middlewares", func() {
var validToken string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Minute
auth.Init(ds)
@ -499,6 +500,36 @@ var _ = Describe("Middlewares", func() {
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Context("JWT credentials", func() {
var usr *model.User
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Minute
auth.Init(ds)
usr = &model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1}
})
It("accepts an unscoped session token", func() {
tokenStr, err := auth.CreateToken(usr)
Expect(err).ToNot(HaveOccurred())
Expect(validateCredentials(usr, "", "", "", tokenStr)).To(Succeed())
})
It("rejects a jellyfin-scoped token", func() {
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth))
})
It("rejects a token with a stale epoch", func() {
tokenStr, err := auth.CreateToken(usr)
Expect(err).ToNot(HaveOccurred())
usr.TokenEpoch = 2
Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth))
})
})
})
})