mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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:
parent
295886cb9a
commit
59810c3d59
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@ -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() {
|
||||
|
||||
7
db/migrations/20260822062750_add_user_token_epoch.sql
Normal file
7
db/migrations/20260822062750_add_user_token_epoch.sql
Normal 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;
|
||||
@ -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]+",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
17
model/request/request_suite_test.go
Normal file
17
model/request/request_suite_test.go
Normal 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")
|
||||
}
|
||||
40
model/request/request_test.go
Normal file
40
model/request/request_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
80
server/nativeapi/user_password_token_refresh_test.go
Normal file
80
server/nativeapi/user_password_token_refresh_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user