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
|
|
|
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{})
|
feat(jellyfin): add Quick Connect sign-in (#6174)
* feat(jellyfin): add Quick Connect sign-in
Jellyfin clients can now sign in without a password: the client shows a
6-digit code, a signed-in user approves it, and the client redeems a secret
for its access token.
- core/quickconnect: in-memory store shared by both routers through wire.
Codes expire after 10 minutes; a secret redeems only once (Jellyfin
allows repeats for 10 minutes); at most 1000 pending requests.
- Jellyfin API: Initiate, Connect, Authorize and AuthenticateWithQuickConnect.
Admins may approve for another user via UserId, like Swiftfin's admin page.
Initiate and redeem share the login rate limiter; Connect does not, since
Finamp and Streamyfin poll it every second.
- Web UI: a Quick Connect item in the user menu looks up the code and shows
the app and device before approving, so a user can't be tricked into
approving an unknown device blindly.
- Jellyfin.QuickConnect option, on by default like Jellyfin. It only matters
when the Jellyfin API is enabled.
* refactor(jellyfin): tidy Quick Connect naming and route guards
Group the Quick Connect routes under one requireQuickConnect guard, make the
request's device a named field so req.Device.ID can't be mistaken for a
request id, and rename the web API response type to quickConnectDevice.
* refactor(jellyfin): remove duplicated Jellyfin date formatting function
* test(jellyfin): set play count and starred in the song fixture literal
* refactor(jellyfin): inline the Quick Connect redeem body and use the shared date helper
* fix(jellyfin): bound the client fields Quick Connect keeps in memory
Initiate is unauthenticated and keeps the Client, Device, DeviceId and Version
header fields for up to ten minutes. With no header size limit, each pending
request could hold about 1 MB, and even a short field kept the whole header
alive because the parsed values are substrings of it. Reject fields over 512
bytes and copy the stored values.
Also answer 500 instead of 401 when the redeem user lookup fails for a reason
other than the user being gone.
* fix(jellyfin): rate-limit Quick Connect code approval
Any signed-in user could try codes without limit on the Jellyfin Authorize
endpoint and the web UI lookup/authorize endpoints, and so could approve
another person's pending device for their own account. Apply the same per-IP
limiter as the login (AuthRequestLimit/AuthWindowLength) to both surfaces.
2026-09-19 14:57:01 -04:00
|
|
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil)
|
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
|
|
|
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))
|
|
|
|
|
})
|
|
|
|
|
})
|