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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

441 lines
12 KiB
Go

package server
import (
"context"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"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"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/gravatar"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var (
ErrNoUsers = errors.New("no users created")
ErrUnauthenticated = errors.New("request not authenticated")
)
func login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
username, password, err := getCredentialsFromBody(r)
if err != nil {
log.Error(r, "Parsing request body", err)
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
return
}
doLogin(ds, username, password, w, r)
}
}
func doLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) {
user, err := validateLogin(ds.User(r.Context()), username, password)
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again")
return
}
if user == nil {
log.Warn(r, "Unsuccessful login", "username", username, "request", r.Header)
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Invalid username or password")
return
}
tokenString, err := auth.CreateToken(user)
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
return
}
payload := buildAuthPayload(user)
payload["token"] = tokenString
_ = rest.RespondWithJSON(w, http.StatusOK, payload)
}
func buildAuthPayload(user *model.User) map[string]any {
payload := map[string]any{
"id": user.ID,
"name": user.Name,
"username": user.UserName,
"isAdmin": user.IsAdmin,
}
if conf.Server.EnableGravatar && user.Email != "" {
payload["avatar"] = gravatar.Url(user.Email, 50)
}
bytes := make([]byte, 3)
_, err := rand.Read(bytes)
if err != nil {
log.Error("Could not create subsonic salt", "user", user.UserName, err)
return payload
}
subsonicSalt := hex.EncodeToString(bytes)
payload["subsonicSalt"] = subsonicSalt
subsonicToken := md5.Sum([]byte(user.Password + subsonicSalt))
payload["subsonicToken"] = hex.EncodeToString(subsonicToken[:])
return payload
}
func getCredentialsFromBody(r *http.Request) (username string, password string, err error) {
data := make(map[string]string)
decoder := json.NewDecoder(r.Body)
if err = decoder.Decode(&data); err != nil {
log.Error(r, "parsing request body", err)
err = errors.New("invalid request payload")
return
}
username = data["username"]
password = data["password"]
return username, password, nil
}
func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
username, password, err := getCredentialsFromBody(r)
if err != nil {
log.Error(r, "parsing request body", err)
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
return
}
c, err := ds.User(r.Context()).CountAll()
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
if c > 0 {
_ = rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin")
return
}
err = createAdminUser(r.Context(), ds, username, password)
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
doLogin(ds, username, password, w, r)
}
}
func createAdminUser(ctx context.Context, ds model.DataStore, username, password string) error {
log.Warn(ctx, "Creating initial user", "user", username)
caser := cases.Title(language.Und)
initialUser := model.User{
ID: id.NewRandom(),
UserName: username,
Name: caser.String(username),
Email: "",
NewPassword: password,
IsAdmin: true,
LastLoginAt: new(time.Now()),
}
err := ds.User(ctx).Put(&initialUser)
if err != nil {
log.Error(ctx, "Could not create initial user", "user", initialUser, err)
}
return nil
}
func validateLogin(userRepo model.UserRepository, userName, password string) (*model.User, error) {
u, err := userRepo.FindByUsernameWithPassword(userName)
if errors.Is(err, model.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
if u.Password != password {
return nil, nil
}
err = userRepo.UpdateLastLoginAt(u.ID)
if err != nil {
log.Error("Could not update LastLoginAt", "user", userName)
}
return u, nil
}
func JWTVerifier(next http.Handler) http.Handler {
return jwtauth.Verify(auth.TokenAuth, tokenFromHeader, jwtauth.TokenFromCookie, jwtauth.TokenFromQuery)(next)
}
func tokenFromHeader(r *http.Request) string {
// Get token from authorization header.
bearer := r.Header.Get(consts.UIAuthorizationHeader)
if len(bearer) > 7 && strings.ToUpper(bearer[0:6]) == "BEARER" {
return bearer[7:]
}
return ""
}
func UsernameFromToken(r *http.Request) string {
token, _, err := jwtauth.FromContext(r.Context())
if err != nil || token == nil {
return ""
}
sub, _ := token.Subject()
if sub == "" {
return ""
}
log.Trace(r, "Found username in JWT token", "username", sub)
return sub
}
func UsernameFromExtAuthHeader(r *http.Request) string {
if conf.Server.ExtAuth.TrustedSources == "" {
return ""
}
reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context())
if !ok {
log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.")
return ""
}
if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
return ""
}
username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
if username == "" {
return ""
}
log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username)
return username
}
func InternalAuth(r *http.Request) string {
username, ok := request.InternalAuthFrom(r.Context())
if !ok {
return ""
}
log.Trace(r, "Found username in InternalAuth", "username", username)
return username
}
func UsernameFromConfig(*http.Request) string {
return conf.Server.DevAutoLoginUsername
}
func contextWithUser(ctx context.Context, ds model.DataStore, username string) (context.Context, error) {
user, err := ds.User(ctx).FindByUsername(username)
if err == nil {
ctx = log.NewContext(ctx, "username", username)
ctx = request.WithUsername(ctx, user.UserName)
return request.WithUser(ctx, *user), nil
}
log.Error(ctx, "Authenticated username not found in DB", "username", username)
return ctx, err
}
func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns ...func(r *http.Request) string) (context.Context, error) {
var username string
for _, fn := range findUsernameFns {
username = fn(r)
if username != "" {
break
}
}
if username == "" {
return nil, ErrUnauthenticated
}
return contextWithUser(r.Context(), ds, username)
}
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 || !tokenAllowed(ctx) {
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// 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) {
token, _, err := jwtauth.FromContext(r.Context())
if err != nil || token == nil {
next.ServeHTTP(w, r)
return
}
ctx := request.WithTokenEpochHolder(r.Context())
rw := &refreshingWriter{ResponseWriter: w, ctx: ctx, token: token}
next.ServeHTTP(rw, r.WithContext(ctx))
rw.setToken()
})
}
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]any {
username := UsernameFromConfig(r)
if username == "" {
username = UsernameFromExtAuthHeader(r)
if username == "" {
return nil
}
}
userRepo := ds.User(r.Context())
user, err := userRepo.FindByUsernameWithPassword(username)
if user == nil || err != nil {
log.Info(r, "User passed in header not found", "user", username)
// Check if this is the first user being created
count, _ := userRepo.CountAll()
isFirstUser := count == 0
newUser := model.User{
ID: id.NewRandom(),
UserName: username,
Name: username,
Email: "",
NewPassword: consts.PasswordAutogenPrefix + id.NewRandom(),
IsAdmin: isFirstUser, // Make the first user an admin
}
err := userRepo.Put(&newUser)
if err != nil {
log.Error(r, "Could not create new user", "user", username, err)
return nil
}
user, err = userRepo.FindByUsernameWithPassword(username)
if user == nil || err != nil {
log.Error(r, "Created user but failed to fetch it", "user", username)
return nil
}
}
err = userRepo.UpdateLastLoginAt(user.ID)
if err != nil {
log.Error(r, "Could not update LastLoginAt", "user", username, err)
return nil
}
return buildAuthPayload(user)
}
func validateIPAgainstList(ip string, comaSeparatedList string) bool {
if comaSeparatedList == "" || ip == "" {
return false
}
cidrs := strings.Split(comaSeparatedList, ",")
// Per https://github.com/golang/go/issues/49825, the remote address
// on a unix socket is '@'
if ip == "@" && strings.HasPrefix(conf.Server.Address, "unix:") {
return slices.Contains(cidrs, "@")
}
if net.ParseIP(ip) == nil {
ip, _, _ = net.SplitHostPort(ip)
}
if ip == "" {
return false
}
testedIP, _, err := net.ParseCIDR(fmt.Sprintf("%s/32", ip))
if err != nil {
return false
}
for _, cidr := range cidrs {
_, ipnet, err := net.ParseCIDR(cidr)
if err == nil && ipnet.Contains(testedIP) {
return true
}
}
return false
}