feat(api): add API v1 login, setup, grant resolution and token minting

This commit is contained in:
Deluan 2026-09-26 01:18:21 -04:00
commit 0bbca1b133
7 changed files with 560 additions and 1 deletions

14
core/apiauth/context.go Normal file
View file

@ -0,0 +1,14 @@
package apiauth
import "context"
type principalKey struct{}
func WithPrincipal(ctx context.Context, p *Principal) context.Context {
return context.WithValue(ctx, principalKey{}, p)
}
func PrincipalFrom(ctx context.Context) (*Principal, bool) {
p, ok := ctx.Value(principalKey{}).(*Principal)
return p, ok
}

View file

@ -0,0 +1,68 @@
package apiauth
import (
"context"
"crypto/subtle"
"errors"
"github.com/navidrome/navidrome/model"
)
type Outcome int
const (
NotMine Outcome = iota
Authenticated
Rejected
Unavailable
)
type CredentialResult struct {
Outcome Outcome
User *model.User
Provider string
PasswordLocal bool
}
type CredentialChecker interface {
Check(ctx context.Context, username, password string) (CredentialResult, error)
}
// checkCredentials asks each checker in turn; only NotMine moves on, so an owning provider's "no" is final.
func checkCredentials(ctx context.Context, checkers []CredentialChecker, username, password string) (CredentialResult, error) {
for _, c := range checkers {
res, err := c.Check(ctx, username, password)
if err != nil {
return CredentialResult{}, err
}
switch res.Outcome {
case NotMine:
continue
case Authenticated:
return res, nil
case Unavailable:
return CredentialResult{}, model.ErrNotAvailable
default:
return CredentialResult{}, model.ErrInvalidAuth
}
}
return CredentialResult{}, model.ErrInvalidAuth
}
type dbChecker struct {
ds model.DataStore
}
func (c dbChecker) Check(ctx context.Context, username, password string) (CredentialResult, error) {
u, err := c.ds.User().FindByUsernameWithPassword(ctx, username)
if errors.Is(err, model.ErrNotFound) {
return CredentialResult{Outcome: NotMine}, nil
}
if err != nil {
return CredentialResult{}, err
}
if subtle.ConstantTimeCompare([]byte(u.Password), []byte(password)) != 1 {
return CredentialResult{Outcome: Rejected}, nil
}
return CredentialResult{Outcome: Authenticated, User: u, Provider: "password", PasswordLocal: true}, nil
}

View file

@ -0,0 +1,63 @@
package apiauth
import (
"context"
"errors"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type fakeChecker struct {
res CredentialResult
err error
hit bool
}
func (f *fakeChecker) Check(context.Context, string, string) (CredentialResult, error) {
f.hit = true
return f.res, f.err
}
var _ = Describe("credential chain", func() {
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
})
It("authenticates against the database with the stored password", func() {
u := createUser(ctx, "pw", false)
res, err := checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, u.UserName, "pw")
Expect(err).ToNot(HaveOccurred())
Expect(res.Outcome).To(Equal(Authenticated))
Expect(res.User.ID).To(Equal(u.ID))
Expect(res.Provider).To(Equal("password"))
Expect(res.PasswordLocal).To(BeTrue())
})
It("rejects a wrong password and an unknown user the same way", func() {
u := createUser(ctx, "pw", false)
_, err := checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, u.UserName, "nope")
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, "ghost", "pw")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("moves on only from NotMine, and an owner's rejection stops the chain", func() {
owner := &fakeChecker{res: CredentialResult{Outcome: Rejected}}
later := &fakeChecker{res: CredentialResult{Outcome: Authenticated, User: &model.User{ID: "x"}}}
_, err := checkCredentials(ctx, []CredentialChecker{&fakeChecker{res: CredentialResult{Outcome: NotMine}}, owner, later}, "a", "b")
Expect(err).To(MatchError(model.ErrInvalidAuth))
Expect(later.hit).To(BeFalse())
})
It("maps Unavailable to ErrNotAvailable and passes through checker errors", func() {
_, err := checkCredentials(ctx, []CredentialChecker{&fakeChecker{res: CredentialResult{Outcome: Unavailable}}}, "a", "b")
Expect(err).To(MatchError(model.ErrNotAvailable))
boom := errors.New("boom")
_, err = checkCredentials(ctx, []CredentialChecker{&fakeChecker{err: boom}}, "a", "b")
Expect(err).To(MatchError(boom))
})
})

View file

@ -24,7 +24,6 @@ var _ = BeforeSuite(func() {
realDS = persistence.New(db.Db())
})
//nolint:unused
func createUser(ctx context.Context, password string, admin bool) model.User {
name := "user-" + id.NewRandom()
u := model.User{UserName: name, Name: name, NewPassword: password, IsAdmin: admin}

View file

@ -0,0 +1,11 @@
package apiauth
import (
"time"
"github.com/navidrome/navidrome/model"
)
func (s *Service) SetClock(now func() time.Time) { s.now = now }
func (s *Service) SetCheckers(f func(model.DataStore) []CredentialChecker) { s.checkers = f }

245
core/apiauth/service.go Normal file
View file

@ -0,0 +1,245 @@
package apiauth
import (
"cmp"
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
const (
TokenTTL = time.Hour
IdleExpiry = 90 * 24 * time.Hour
cacheTTL = 30 * time.Second
touchInterval = 5 * time.Minute
)
var (
ErrInsufficientScope = errors.New("insufficient scope")
ErrPasswordManagedExternally = errors.New("password is managed externally")
ErrCurrentPasswordMismatch = errors.New("current password does not match")
)
type ClientMeta struct {
Name string
Client string
ClientVersion string
}
type Issued struct {
Secret string
Grant model.Grant
User model.User
}
type AccessToken struct {
Token string
ExpiresIn time.Duration
Scopes []string
}
type Principal struct {
User model.User
GrantID string
Scopes []string
}
type Service struct {
ds model.DataStore
checkers func(ds model.DataStore) []CredentialChecker // per datastore, so password change can check inside its transaction
cache *livenessCache
now func() time.Time
signer func() (*signer, error)
}
func New(ds model.DataStore) *Service {
s := &Service{
ds: ds,
checkers: func(ds model.DataStore) []CredentialChecker {
return []CredentialChecker{dbChecker{ds: ds}}
},
cache: newLivenessCache(cacheTTL),
now: time.Now,
}
// Loaded on first use so building the router never touches the database.
s.signer = sync.OnceValues(func() (*signer, error) {
return loadSigner(context.Background(), ds, func() time.Time { return s.now() })
})
return s
}
func PasswordChangeable(u model.User) bool {
return u.IsAdmin || conf.Server.EnableUserEditing
}
func (s *Service) Login(ctx context.Context, username, password string, meta ClientMeta, scopes []string) (*Issued, error) {
res, err := checkCredentials(ctx, s.checkers(s.ds), username, password)
if err != nil {
return nil, err
}
issued, err := s.issue(ctx, s.ds, *res.User, res.Provider, meta, scopes)
if err != nil {
return nil, err
}
if err := s.ds.User().UpdateLastLoginAt(ctx, res.User.ID); err != nil {
log.Warn(ctx, "API v1: could not update last login", "user", res.User.UserName, err)
}
return issued, nil
}
func (s *Service) Setup(ctx context.Context, username, password string, meta ClientMeta, scopes []string) (*Issued, error) {
var issued *Issued
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
u, err := auth.CreateFirstAdmin(ctx, tx, username, password)
if err != nil {
return err
}
issued, err = s.issue(ctx, tx, *u, "setup", meta, scopes)
return err
})
return issued, err
}
// issue stores a grant bound to the epoch read with the user, so a racing password change leaves it dead.
func (s *Service) issue(ctx context.Context, ds model.DataStore, u model.User, provider string, meta ClientMeta, scopes []string) (*Issued, error) {
secret, hash := newSecret()
g := model.Grant{
UserID: u.ID,
Name: cmp.Or(meta.Name, meta.Client),
Client: meta.Client,
ClientVersion: meta.ClientVersion,
Scopes: Entitled(scopes, u.IsAdmin),
Provider: provider,
SecretHash: hash,
UserEpoch: u.TokenEpoch,
CreatedAt: s.now(),
}
if err := ds.Grant().Put(ctx, &g); err != nil {
return nil, fmt.Errorf("storing grant: %w", err)
}
return &Issued{Secret: secret, Grant: g, User: u}, nil
}
func (s *Service) ResolveGrant(ctx context.Context, secret, ip string) (*Principal, error) {
g, err := s.ds.Grant().FindBySecretHash(ctx, hashSecret(secret))
if errors.Is(err, model.ErrNotFound) {
return nil, model.ErrInvalidAuth
}
if err != nil {
return nil, err
}
lastActivity := g.CreatedAt
if g.LastUsedAt != nil {
lastActivity = *g.LastUsedAt
}
if !s.now().Before(lastActivity.Add(IdleExpiry)) {
s.dropGrant(ctx, g.ID)
return nil, model.ErrInvalidAuth
}
u, err := s.loadUser(ctx, g.UserID)
if err != nil {
return nil, err
}
if g.UserEpoch != u.TokenEpoch {
if g, u, err = s.settleEpoch(ctx, g.ID); err != nil {
return nil, err
}
}
s.touch(ctx, g.ID, ip, g.LastUsedAt)
return &Principal{User: *u, GrantID: g.ID, Scopes: Expand(g.Scopes, u.IsAdmin)}, nil
}
func (s *Service) Mint(ctx context.Context, p *Principal, requested []string) (*AccessToken, error) {
sg, err := s.signer()
if err != nil {
return nil, err
}
now := s.now()
scopes := Attenuate(p.Scopes, requested)
tok, err := sg.sign(claims{UserID: p.User.ID, GrantID: p.GrantID, Scopes: scopes, IssuedAt: now, ExpiresAt: now.Add(TokenTTL)})
if err != nil {
return nil, fmt.Errorf("signing access token: %w", err)
}
return &AccessToken{Token: tok, ExpiresIn: TokenTTL, Scopes: scopes}, nil
}
func (s *Service) loadUser(ctx context.Context, userID string) (*model.User, error) {
u, err := s.ds.User().Get(ctx, userID)
if errors.Is(err, model.ErrNotFound) {
return nil, model.ErrInvalidAuth
}
return u, err
}
func (s *Service) dropGrant(ctx context.Context, id string) {
s.cache.evict(id)
if err := s.ds.Grant().Delete(ctx, id); err != nil {
log.Warn(ctx, "API v1: could not delete dead grant", "grant", id, err)
}
}
// settleEpoch re-reads grant and user in one read transaction: separate reads can straddle a password
// change and make a kept grant look dead. The delete only fires while the grant is on the epoch seen here.
func (s *Service) settleEpoch(ctx context.Context, grantID string) (*model.Grant, *model.User, error) {
var g *model.Grant
var u *model.User
err := s.ds.WithTx(func(tx model.DataStore) error {
var err error
if g, err = tx.Grant().Get(ctx, grantID); err != nil {
return err
}
u, err = tx.User().Get(ctx, g.UserID)
return err
})
if errors.Is(err, model.ErrNotFound) {
s.cache.evict(grantID)
return nil, nil, model.ErrInvalidAuth
}
if err != nil {
return nil, nil, err
}
if g.UserEpoch != u.TokenEpoch {
s.cache.evict(grantID)
if err := s.ds.Grant().DeleteIfEpoch(ctx, grantID, g.UserEpoch); err != nil {
log.Warn(ctx, "API v1: could not delete dead grant", "grant", grantID, err)
}
return nil, nil, model.ErrInvalidAuth
}
return g, u, nil
}
// touch writes last_used at most every touchInterval; the SQL condition keeps that true across nodes.
func (s *Service) touch(ctx context.Context, id, ip string, lastUsed *time.Time) {
now := s.now()
if lastUsed != nil && now.Before(lastUsed.Add(touchInterval)) {
return
}
if err := s.ds.Grant().Touch(ctx, id, ip, now, now.Add(-touchInterval)); err != nil {
log.Warn(ctx, "API v1: could not record grant use", "grant", id, err)
return
}
s.cache.markUsed(id, now)
}
func (s *Service) Authenticate(ctx context.Context, token, ip string) (*Principal, error) {
sg, err := s.signer()
if err != nil {
return nil, err
}
c, err := sg.parse(token)
if err != nil {
return nil, err
}
u, err := s.loadUser(ctx, c.UserID)
if err != nil {
return nil, err
}
return &Principal{User: *u, GrantID: c.GrantID, Scopes: c.Scopes}, nil
}

View file

@ -0,0 +1,159 @@
package apiauth
import (
"context"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var meta = ClientMeta{Name: "Living room", Client: "TestApp", ClientVersion: "1.0"}
var _ = Describe("Service: grants and tokens", func() {
var ctx context.Context
var svc *Service
var now time.Time
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
now = time.Now().UTC().Truncate(time.Second)
svc = New(realDS)
svc.SetClock(func() time.Time { return now })
})
Describe("Login", func() {
It("creates a grant storing all, the user's epoch and the client metadata", func() {
u := createUser(ctx, "pw", false)
issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil)
Expect(err).ToNot(HaveOccurred())
Expect(issued.Secret).To(HavePrefix("ndg_"))
Expect(issued.User.ID).To(Equal(u.ID))
Expect(issued.Grant.Scopes).To(Equal(model.Scopes{ScopeAll}))
Expect(issued.Grant.Provider).To(Equal("password"))
Expect(issued.Grant.Name).To(Equal("Living room"))
Expect(issued.Grant.UserEpoch).To(Equal(u.TokenEpoch))
stored, err := realDS.Grant().FindBySecretHash(ctx, hashSecret(issued.Secret))
Expect(err).ToNot(HaveOccurred())
Expect(stored.ID).To(Equal(issued.Grant.ID))
})
It("defaults the grant name to the client", func() {
u := createUser(ctx, "pw", false)
issued, err := svc.Login(ctx, u.UserName, "pw", ClientMeta{Client: "OnlyClient"}, nil)
Expect(err).ToNot(HaveOccurred())
Expect(issued.Grant.Name).To(Equal("OnlyClient"))
})
It("accepts the username in any case", func() {
u := createUser(ctx, "pw", false)
issued, err := svc.Login(ctx, strings.ToUpper(u.UserName), "pw", meta, nil)
Expect(err).ToNot(HaveOccurred())
Expect(issued.User.ID).To(Equal(u.ID))
})
It("stores only known requested scopes", func() {
u := createUser(ctx, "pw", false)
issued, err := svc.Login(ctx, u.UserName, "pw", meta, []string{"read", "future", "admin"})
Expect(err).ToNot(HaveOccurred())
Expect(issued.Grant.Scopes).To(Equal(model.Scopes{ScopeRead}))
})
It("fails with ErrInvalidAuth for bad credentials", func() {
u := createUser(ctx, "pw", false)
_, err := svc.Login(ctx, u.UserName, "wrong", meta, nil)
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Describe("Setup", func() {
It("refuses when users exist", func() {
createUser(ctx, "pw", false)
_, err := svc.Setup(ctx, "newadmin", "pw", meta, nil)
Expect(err).To(MatchError(auth.ErrSetupComplete))
})
// The empty-database path is covered end to end in server/apiv1, which owns a fresh DB.
})
Describe("ResolveGrant and Mint", func() {
It("mints a token with the grant's expanded scopes and a 1h lifetime", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
p, err := svc.ResolveGrant(ctx, issued.Secret, "10.0.0.9")
Expect(err).ToNot(HaveOccurred())
Expect(p.GrantID).To(Equal(issued.Grant.ID))
Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))
tok, err := svc.Mint(ctx, p, nil)
Expect(err).ToNot(HaveOccurred())
Expect(tok.ExpiresIn).To(Equal(time.Hour))
Expect(tok.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))
principal, err := svc.Authenticate(ctx, tok.Token, "10.0.0.9")
Expect(err).ToNot(HaveOccurred())
Expect(principal.User.ID).To(Equal(u.ID))
})
It("attenuates to the requested subset", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
p, _ := svc.ResolveGrant(ctx, issued.Secret, "")
tok, err := svc.Mint(ctx, p, []string{"read", "sync"})
Expect(err).ToNot(HaveOccurred())
Expect(tok.Scopes).To(Equal([]string{ScopeRead}))
})
It("counts minting as use", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
p, _ := svc.ResolveGrant(ctx, issued.Secret, "10.0.0.9")
_, err := svc.Mint(ctx, p, nil)
Expect(err).ToNot(HaveOccurred())
g, _ := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(g.LastUsedAt).ToNot(BeNil())
Expect(g.LastUsedIP).To(Equal("10.0.0.9"))
})
It("rejects unknown secrets", func() {
_, err := svc.ResolveGrant(ctx, "ndg_unknown", "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("deletes and rejects a grant idle for 90 days, including one never used", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
now = now.Add(IdleExpiry + time.Second)
_, err := svc.ResolveGrant(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("rejects a grant whose epoch is behind the user's", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
u.NewPassword = "changed-elsewhere"
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
_, err := svc.ResolveGrant(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Describe("PasswordChangeable", func() {
It("follows EnableUserEditing for non-admins only", func() {
conf.Server.EnableUserEditing = false
Expect(PasswordChangeable(model.User{IsAdmin: true})).To(BeTrue())
Expect(PasswordChangeable(model.User{})).To(BeFalse())
conf.Server.EnableUserEditing = true
Expect(PasswordChangeable(model.User{})).To(BeTrue())
})
})
})