mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* 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
149 lines
4.2 KiB
Go
149 lines
4.2 KiB
Go
package auth_test
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/go-chi/jwtauth/v5"
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Claims", func() {
|
|
Describe("ToMap", func() {
|
|
It("includes only non-zero fields", func() {
|
|
c := auth.Claims{
|
|
Issuer: "ND",
|
|
Subject: "johndoe",
|
|
UserID: "123",
|
|
IsAdmin: true,
|
|
}
|
|
m := c.ToMap()
|
|
Expect(m).To(HaveKeyWithValue("iss", "ND"))
|
|
Expect(m).To(HaveKeyWithValue("sub", "johndoe"))
|
|
Expect(m).To(HaveKeyWithValue("uid", "123"))
|
|
Expect(m).To(HaveKeyWithValue("adm", true))
|
|
Expect(m).NotTo(HaveKey("exp"))
|
|
Expect(m).NotTo(HaveKey("iat"))
|
|
Expect(m).NotTo(HaveKey("id"))
|
|
Expect(m).NotTo(HaveKey("f"))
|
|
Expect(m).NotTo(HaveKey("b"))
|
|
Expect(m).NotTo(HaveKey("sid"))
|
|
})
|
|
|
|
It("includes expiration and issued-at when set", func() {
|
|
now := time.Now()
|
|
c := auth.Claims{
|
|
IssuedAt: now,
|
|
ExpiresAt: now.Add(time.Hour),
|
|
}
|
|
m := c.ToMap()
|
|
Expect(m).To(HaveKey("iat"))
|
|
Expect(m).To(HaveKey("exp"))
|
|
})
|
|
|
|
It("includes custom claims for public tokens", func() {
|
|
c := auth.Claims{
|
|
ID: "al-123",
|
|
Format: "mp3",
|
|
BitRate: 192,
|
|
}
|
|
m := c.ToMap()
|
|
Expect(m).To(HaveKeyWithValue("id", "al-123"))
|
|
Expect(m).To(HaveKeyWithValue("f", "mp3"))
|
|
Expect(m).To(HaveKeyWithValue("b", 192))
|
|
})
|
|
|
|
It("includes share ID claim when set", func() {
|
|
c := auth.Claims{ShareID: "abc1234567"}
|
|
m := c.ToMap()
|
|
Expect(m).To(HaveKeyWithValue("sid", "abc1234567"))
|
|
})
|
|
})
|
|
|
|
Describe("ClaimsFromToken", func() {
|
|
It("round-trips session claims through encode/decode", func() {
|
|
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
|
|
now := time.Now().Truncate(time.Second)
|
|
original := auth.Claims{
|
|
Issuer: "ND",
|
|
Subject: "johndoe",
|
|
UserID: "123",
|
|
IsAdmin: true,
|
|
}
|
|
m := original.ToMap()
|
|
m["iat"] = now.UTC().Unix()
|
|
token, _, err := tokenAuth.Encode(m)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
c := auth.ClaimsFromToken(token)
|
|
Expect(c.Issuer).To(Equal("ND"))
|
|
Expect(c.Subject).To(Equal("johndoe"))
|
|
Expect(c.UserID).To(Equal("123"))
|
|
Expect(c.IsAdmin).To(BeTrue())
|
|
Expect(c.IssuedAt.UTC()).To(Equal(now.UTC()))
|
|
})
|
|
|
|
It("round-trips public token claims through encode/decode", func() {
|
|
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
|
|
original := auth.Claims{
|
|
Issuer: "ND",
|
|
ID: "al-456",
|
|
Format: "opus",
|
|
BitRate: 128,
|
|
ShareID: "abc1234567",
|
|
}
|
|
token, _, err := tokenAuth.Encode(original.ToMap())
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
c := auth.ClaimsFromToken(token)
|
|
Expect(c.Issuer).To(Equal("ND"))
|
|
Expect(c.ID).To(Equal("al-456"))
|
|
Expect(c.ShareID).To(Equal("abc1234567"))
|
|
Expect(c.Format).To(Equal("opus"))
|
|
Expect(c.BitRate).To(Equal(128))
|
|
})
|
|
})
|
|
|
|
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())
|
|
})
|
|
})
|
|
|
|
})
|