* feat(persistence): store hashed API keys on players
* feat(core): refresh key-bound players without renaming them
Add Players.Touch, which records usage for a player already identified by
an API key without guessing its identity or overwriting its name. Register
also stops renaming players that have an API key.
Register no longer returns player save errors (or a stale FindMatch
ErrNotFound when the save is rate-limited); save failures are only logged,
and only the transcoding lookup error is returned, same as Touch.
* feat(subsonic): authenticate with OpenSubsonic API keys
Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>
* feat(subsonic): add tokenInfo and advertise apiKeyAuthentication
* feat(server): add endpoints to generate and revoke player API keys
* feat(ui): manage player API keys
Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>
* fix(subsonic): throttle API keys per key and IP
A stale key on one device exhausted the shared per-IP bucket and locked out
every valid key from the same IP. The limiter only stores a hash of the bucket
string, so the key is not retained. Also adds e2e coverage of API key auth
through the real repository, and clarifies the player resolution log message.
* fix(ui): keep the new API key dialog open until closed
The key is shown only once, so Escape and backdrop clicks no longer dismiss
it. Also clarifies when the key can be used as a password.
* refactor: simplify API key code paths
Share the player refresh tail between Register and Touch, fold the
ownership-filtered write tail into execOwned, parse the query once for
apiKey conflicts, derive HasAPIKey in the player mock, share the player
form inputs between create and edit, and pick the delete button by key
state instead of spreading conditional props.
* feat(players): set API keys through the player record
The key is a write-only apiKey field applied on save: required and owner-only on create, optional on edit, empty to revoke. Replaces the generate/revoke endpoints.
* fix(players): reject API keys already in use
Creating or editing a player with a key another player already has now returns a validation error instead of a 500, and a create that loses the race no longer leaves a keyless player behind. Ownership is checked before the key on create.
* feat(ui): edit player API keys as a form field
Replaces the show-once dialog, whose icon-less Close button was invisible on mobile. The key is generated in the browser, required and pre-filled on create.
* fix(ui): keep new player API keys out of the record cache
The json-server create response echoes the request body, and undoable edits merge the payload into the cache, so the key could reappear on the edit page. Strip it from the create result and save player edits pessimistically. Also fall back to a prompt when the clipboard write fails.
* fix(ui): polish player API key field
Set userId on the created player record so owner actions show immediately, and show a neutral no-key message to non-owners.
* refactor: simplify player API key create and field
Write the key hash in the create INSERT so the unique index settles
races, re-read the created player instead of hand-building the cached
record, reuse isWritable for the revoke check, and collapse the key
field's derived state and generate/regenerate buttons.
* fix(ui): let the API key field size like other inputs
fullWidth is now opt-in instead of forced.
* fix(ui): align the API key field with other player inputs
Apply react-admin's input className, move the actions (now including Copy) below the field, and use a monospace font so the whole key fits.
* fix(ui): redirect to the player list after create
Matches the other create pages.
* refactor(persistence): name the write-access rule for owned rows
Owned-row writes now say which row they target and who may write it: ownedRow(rowID, ownerOrAdmin|ownerOnly) builds the WHERE, updateOwnedRow applies it, and SetAPIKey uses ownerOnly instead of a hand-built user_id filter. updateOwned/deleteOwned keep their signatures.
* fix(players): apply an edit's key change and fields atomically
Update now runs SetAPIKey and the column update in one transaction. Also shares the key format check, drops FindByAPIKey's unneeded empty-key guard, and sets the context username only on the apiKey path.
* fix(subsonic): treat any credential param sent with apiKey as a conflict
The spec requires error 43 when u, p, t or s is present with apiKey, even with an empty value.
* refactor(subsonic): leave the player cookie code unchanged for key-bound requests
Return early instead of wrapping the cookie block, so the diff (and CodeQL's view of it) matches master.
* fix(subsonic): don't count key lookup errors as failed logins
A database error while checking a key sent as the password now surfaces as a server error instead of a bad password, so it no longer feeds the failed-login limiter.
* feat(players): use nds_ as the API key prefix
Part of a Navidrome secret prefix family (nd + a letter for the kind), alongside ndg_ for API v1 grants.
* feat(ui): make player API keys easier to find
Label the Settings menu entry "Players & API keys", add an API key
filter to the player list, show the key icon in the mobile list, and
add Brazilian Portuguese translations for the new player strings.
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(ui): always show the player API key filter
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): hide the unset Last Seen date in the player list
Players created by hand have no last_seen yet, which showed as 12/31/1.
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>
* refactor(persistence): adopt generic deluan/rest repository API
Pin deluan/rest to the refactor branch. REST-facing repository methods
take a context and return typed values. Drop DataStore.Resource and
ResourceRepository; the native API names typed repositories directly
through a per-request adapter that later commits remove.
* refactor(persistence): base repository helpers take a context
* refactor(persistence): LibraryRepository takes a context per call
* refactor(persistence): PropertyRepository takes a context per call
* refactor(persistence): UserPropsRepository takes a context per call
* refactor(persistence): TranscodingRepository takes a context per call
* refactor(persistence): ShareRepository takes a context per call
* refactor(persistence): PlayerRepository takes a context per call
* refactor(persistence): RadioRepository takes a context per call
* refactor(persistence): PlayQueueRepository takes a context per call
* refactor(persistence): Tag and Genre repositories take a context per call
* refactor(persistence): PluginRepository takes a context per call
* refactor(persistence): Scrobble repositories take a context per call
* refactor(persistence): FolderRepository takes a context per call
* refactor(persistence): Artwork repositories take a context per call
* refactor(persistence): UserRepository takes a context per call
* refactor(persistence): ArtistRepository takes a context per call
ReadAll no longer rewrites the shared sort mappings for the role filter;
it works on a per-call copy.
* test(persistence): assert artist role sort sanitization in ReadAll
* refactor(persistence): AlbumRepository takes a context per call
* test(persistence): pass the test context to album repository helpers
* refactor(persistence): MediaFileRepository takes a context per call
* refactor(persistence): Playlist repositories take a context per call
* refactor(persistence): build all repositories once per store
* refactor(core): REST repository wrappers are built once
* refactor(persistence): repositories are stateless
Remove the context field from the base repository and the per-request
REST adapter. Enable the containedctx linter so no repository can hold a
request context again.
* chore(lint): skip containedctx in test files
* refactor: share simplifications from the stateless repositories sweep
Add deleteOwnedAll on sqlRepository and use it in player/share Delete
to remove the duplicated bulk-delete loop; have Share.Repository()
return model.ShareRepository so subsonic sharing.go drops its repeated
type assertions.
* chore(core): assert REST wrappers implement Persistable
* chore: reformat imports
* perf(persistence): build repositories on first use
Each transaction store used to construct all 21 repositories up front,
paying for filter and sort mapping setup the block never touched. Fields
are now sync.OnceValue thunks, so a store only builds what it uses.
* fix(persistence): clean plugin references per deleted user
A bulk user delete that fails on a later id had already removed the
earlier rows but skipped their plugin cleanup. Cleanup now runs right
after each successful delete.
* fix(core): unload disabled plugins even when a user delete fails
A bulk delete can fail on a later id after earlier users were removed
and their plugins auto-disabled. The wrapper returned before unloading,
leaving those plugins running until the next successful delete or a
restart.
* chore(deps): pin deluan/rest to v1.0.1
Replaces the pseudo-version of the refactor branch with the tagged
release. REST error messages now name the bare type (Artist, not
model.Artist).
* test: use the spec context instead of context.Background()
Replace the context.Background()/context.TODO() calls this branch added
to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so
repository calls are bound to the running spec's lifetime.
* test: declare the spec context once per Describe
Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
* fix(subsonic): limit failed authentication attempts per client IP and username
The Subsonic API checked credentials on every request with no limit on failures, so any
account could be brute-forced over /rest/*. Failed u+p, t+s and jwt attempts are now capped
per (client IP, lower-cased username) using AuthRequestLimit and AuthWindowLength, the same
settings that guard the UI login. Every request carries credentials, so only failures count:
a slot is taken before the check and given back on success or on a server error, which also
stops concurrent guesses from overshooting the limit.
Blocked attempts get the same response as a wrong password (HTTP 200, error code 40, no
Retry-After), so an attacker cannot tell a block from a wrong guess. Reverse proxy and
internal authentication are not limited. The client IP helper behind ClientIPRateLimiter is
now exported as server.ClientIP, so spoofed forwarding headers cannot open a fresh bucket.
* refactor(subsonic): simplify failed authentication limiter
Release the limiter slot from a single place in authenticate(), after the user lookup and
credential check, instead of separately in the canceled branch. Store attempt counters by
value instead of by pointer, and drop limiter unit tests that only repeated the middleware
specs.
* fix(subsonic): wait for an in-flight auth check instead of rejecting
Slots were reserved before the credential check and only released afterwards, so once
AuthRequestLimit checks for the same client IP and username overlapped, the next request was
answered with error code 40 even when its credentials were valid. Clients that fan out parallel
requests hit this constantly: a burst of six valid logins lost one, a burst of fifty lost forty
five, and the web UI authenticates its own /rest calls the same way.
A key now carries a slot channel of AuthRequestLimit capacity, and a request waits on it rather
than failing when other checks for that key are in flight. Failures are recorded after the check,
and a request is only rejected when the key already reached the limit within the window. A waiting
request gives up if its context is canceled. Concurrent guesses still cannot run unchecked: at most
AuthRequestLimit checks run at once and the rest are turned away as soon as the failures land.
* docs(subsonic): state the real guess ceiling of the auth limiter
The comment claimed a burst cannot overshoot, which reads as a hard cap of AuthRequestLimit. Allowing concurrent checks means a window admits up to 2*limit-1 guesses, so say that instead.
* 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
* fix: require admin for radio mutations
Subsonic internet radio station mutation endpoints are admin-only in the Subsonic and OpenSubsonic specs, but the router only required an authenticated player. Add a reusable Subsonic admin middleware and apply it to create, update, and delete radio routes while leaving the list endpoint available to authenticated users. Cover the middleware and router behavior with unit and e2e tests.
* fix: streamline admin-only routes for internet radio station management
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: use admin-only middleware for starting scans
Signed-off-by: Deluan <deluan@navidrome.org>
* test: align start scan authorization coverage
StartScan authorization now lives in the shared Subsonic admin middleware instead of the handler. Remove the obsolete direct handler unit assertion so the package tests reflect the route-level guard covered by middleware and e2e tests.
* fix: require admin for getUsers
The Subsonic getUsers endpoint exposes user-list semantics and should use the same shared admin middleware as other admin-only management endpoints. Apply the route-level guard while leaving getUser unchanged, and update the multi-user e2e coverage to expect regular users to receive an authorization failure.
* test: cover admin-only Subsonic access
Add e2e coverage that admins can still call getUsers after the route-level guard and that regular authenticated users can still list internet radio stations. These cases capture the access boundaries raised during PR review.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
* Rename external auth options
ReverseProxyWhitelist was regularly confusing users that enabled it for
non-authenticating reverse proxy setups.
The new option name makes it clear that it's related to authentication, not
just reverse proxies.
* small refactor
Signed-off-by: Deluan <deluan@navidrome.org>
* add test
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>