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
123 lines
2.7 KiB
Go
123 lines
2.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/lestrrat-go/jwx/v3/jwt"
|
|
)
|
|
|
|
// Claims represents the typed JWT claims used throughout Navidrome,
|
|
// replacing the untyped map[string]any approach.
|
|
type Claims struct {
|
|
// Standard JWT claims
|
|
Issuer string
|
|
Subject string // username for session tokens
|
|
Audience []string // which API may accept this token; empty means any
|
|
IssuedAt time.Time
|
|
ExpiresAt time.Time
|
|
|
|
// Custom claims
|
|
UserID string // "uid"
|
|
IsAdmin bool // "adm"
|
|
ID string // "id" - artwork/mediafile ID
|
|
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().
|
|
// Only non-zero fields are included.
|
|
func (c Claims) ToMap() map[string]any {
|
|
m := make(map[string]any)
|
|
if c.Issuer != "" {
|
|
m[jwt.IssuerKey] = c.Issuer
|
|
}
|
|
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()
|
|
}
|
|
if !c.ExpiresAt.IsZero() {
|
|
m[jwt.ExpirationKey] = c.ExpiresAt.UTC().Unix()
|
|
}
|
|
if c.UserID != "" {
|
|
m["uid"] = c.UserID
|
|
}
|
|
if c.IsAdmin {
|
|
m["adm"] = c.IsAdmin
|
|
}
|
|
if c.ID != "" {
|
|
m["id"] = c.ID
|
|
}
|
|
if c.Format != "" {
|
|
m["f"] = c.Format
|
|
}
|
|
if c.BitRate != 0 {
|
|
m["b"] = c.BitRate
|
|
}
|
|
if c.ShareID != "" {
|
|
m["sid"] = c.ShareID
|
|
}
|
|
if c.Epoch != 0 {
|
|
m["ep"] = c.Epoch
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (c Claims) WithExpiresAt(t time.Time) Claims {
|
|
c.ExpiresAt = t
|
|
return c
|
|
}
|
|
|
|
// ClaimsFromToken extracts Claims directly from a jwt.Token using token.Get().
|
|
func ClaimsFromToken(token jwt.Token) Claims {
|
|
var c Claims
|
|
c.Issuer, _ = token.Issuer()
|
|
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 {
|
|
c.UserID = uid
|
|
}
|
|
var adm bool
|
|
if err := token.Get("adm", &adm); err == nil {
|
|
c.IsAdmin = adm
|
|
}
|
|
var id string
|
|
if err := token.Get("id", &id); err == nil {
|
|
c.ID = id
|
|
}
|
|
var f string
|
|
if err := token.Get("f", &f); err == nil {
|
|
c.Format = f
|
|
}
|
|
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
|
|
}
|