Compare commits

...

38 commits

Author SHA1 Message Date
Deluan Quintão
a60a9e6642
Merge branch 'master' into apiv1-auth 2026-09-29 12:18:11 -04:00
Deluan
58090b42ec fix(log): redact marked secrets in every field type
redactSecrets only looked at strings, maps and errors, so a marked secret
inside a slice, struct or []byte field was logged as is, and a typed-nil
error field made it panic. It now renders each field with fmt.Sprint, as
the text formatter does (which also survives typed-nil errors), and writes
[]byte raw.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-29 10:37:53 -04:00
Deluan Quintão
d3a434a610
Merge branch 'master' into apiv1-auth 2026-09-28 23:34:40 -04:00
Deluan
cbc22b09cb refactor(api): group API v1 handlers into one file per OpenAPI tag
Handlers live in <tag>_handlers.go, so the package grows by tag rather than
by endpoint, and shared convention helpers keep plain names without
clashing with tag files.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-28 22:08:01 -04:00
Deluan
0628721006 refactor(api): simplify API v1 auth after dropping access tokens
- Fold Allowed into Expand and drop the ErrInsufficientScope sentinel; the
  gate's scopeError is now the only source of insufficient_scope.
- Replace the two-value authKind with a public flag, inline loadUser, and
  pass the grant to touch.
- Read a declared request body once before validation, so the JSON checks
  and the handler no longer depend on kin-openapi restoring the exact bytes.
- Merge the Service tests into one file and drop specs that only covered
  the removed liveness cache. The revoked-during-password-change spec now
  revokes after the gate authenticates, so it reaches ChangePassword again.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-28 21:47:16 -04:00
Deluan
9655c97b84 test(log): compute the expected source line instead of hard-coding it
Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-28 21:47:16 -04:00
Deluan
04b9e97eb2 fix(api): stop the API v1 request validator from rewriting bodies
Filling schema defaults made kin-openapi re-encode the body, so trailing data
after the JSON value of POST /auth/password was silently dropped instead of
answering 400 like the other endpoints. The handler already applies the
revokeOtherGrants default itself.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-28 21:06:31 -04:00
Deluan
d1b876097f refactor(api): use the grant secret as the API v1 bearer credential
API v1 no longer mints short-lived JWT access tokens. Clients send the grant
secret from POST /auth/login or /auth/setup as `Authorization: Bearer` on
every request.

Every request already looked the grant up in the database, so the JWT gave
no speed or revocation benefit and only added a refresh loop, which early
client authors pushed back on. The grant already is an API key: one per
client sign-in, scoped and revocable. Revocation is now immediate on every
node; the contract promises "within one minute".

Removed: POST /auth/token, the grantAuth scheme, the TokenRequest and
AccessToken schemas, the token_expired problem code, the API v1 JWT signer
and its signing key, the grant liveness cache, and PropertyRepository.PutIfAbsent.
ResolveGrant is now Authenticate.

Short-lived tokens return later only as narrow media tokens for
?access_token= on media URLs, together with the media endpoints.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-28 21:05:57 -04:00
Deluan
052be800a7 test(log): fix the source line assertion after the import change
Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-28 21:05:56 -04:00
Deluan
1b5c68a203 refactor(log): mark secrets on their own line and cover session keys
Mark secret values with log.WithSecrets on a separate line instead of
nesting the call in argument lists. Also mark Last.fm/ListenBrainz session
keys written through SessionKeys.Put and the PasswordEncryptionKey checksum,
which still reached trace logs, and ignore values shorter than 8 characters
so a short plaintext marked after a failed encryption cannot mangle SQL text
or the [REDACTED] marker.
2026-09-28 20:06:28 -04:00
Deluan
ddcc611af3 refactor(log): redact caller-marked secret values in the log hook
Replaces the statement-wide SQL arg redaction from the previous commit,
which hid every arg of property and password writes (user names, emails,
scanner properties) and made troubleshooting harder.

log.WithSecrets marks values on a context, log calls now pass their
context to the logrus entry, and the redaction hook replaces those values
in the message and fields. logSQL logs the real args again; only the
encrypted password, the API v1 key and the JWT secrets are marked.
2026-09-28 20:06:28 -04:00
Deluan
c205b894aa fix(api): stop sending X-RateLimit headers from API v1
Rate-limit counts are per node, so X-RateLimit-Remaining would mislead
clients once API v1 runs behind more than one instance. API v1 now sends
only Retry-After on 429; v0 and Jellyfin limiters keep their headers.
2026-09-28 20:06:28 -04:00
Deluan
8789561b60 fix(api): consolidate grant deletes and harden API v1 auth after review
- GrantRepository keeps three deletes: DeleteForUser, DeleteStaleEpochs
  (replaces DeleteOtherEpochs and DeleteIfEpoch) and DeleteIdle. Delete(id)
  is gone; the idle path in ResolveGrant now calls DeleteIdle, so a grant
  renewed by another node between the read and the delete survives.
  settleEpoch deletes the user's grants below the snapshot's epoch, which
  is safe outside the transaction because epochs only move forward.
- The "dead grants on an older epoch are only deleted when presented"
  policy note moves from the repository to core ListGrants.
- SQL trace logging no longer prints the args of property writes (signing
  keys) or user password writes, both encrypted with a key that may be the
  public default. The SQL statement is still logged.
- The spec gate rejects JSON body keys that differ from a declared property
  only in case. kin-openapi validates exact names while encoding/json
  decodes case-insensitively, so {"scopes":[],"Scopes":null} minted a
  token with every scope and a "Client" key skipped maxLength.
  It also rejects data after the first JSON value, which the handlers'
  decoder ignores and which let a body skip the alias check.
- The liveness cache trims its eviction log on evict, not only on put, so
  evict-only traffic stays bounded; the floor still drops stale fills.
- createAccessToken, login and setupFirstAdmin declare Cache-Control:
  no-store on their success responses.
2026-09-28 20:06:28 -04:00
Deluan
d368eef3ce fix(log): redact named string types instead of panicking
The redaction hook matched fields by reflect.Kind but read them with a
v.(string) type assertion, so any named string type (such as an enum)
panicked the log call. API v1 problem logging hit this on every error.
2026-09-28 20:06:28 -04:00
Deluan
550e03976c refactor(api): shorten the grant touch comment 2026-09-28 20:06:28 -04:00
Deluan
45d8a7724d test(api): share API v1 test helpers and check scopes in Go
Move the end-to-end call/setup/mint helpers to a suite-level test client
and the apiauth login/mustMint helpers to package level. Replace the
hardcoded vacuum x-scope enum with a Go test that every known scope is
a valid spec Scope; the gate already rejects unknown x-scope values.
2026-09-28 20:06:28 -04:00
Deluan
c479c4f581 refactor(api): tighten API v1 auth internals and first-admin setup
Load the signer lock-free once cached, read the signing key before
generating one, cap the liveness cache at its limit, share one
grantable-scope predicate, and let CreateFirstAdmin open its own locked
transaction with an optional in-transaction follow-up.
2026-09-28 20:06:28 -04:00
Deluan
2aca5fe365 refactor(api): simplify the API v1 spec gate
Key operations by a struct, share the validation options, derive the
route method from chi, and set every rule-derived field in buildGateOp.
Build WWW-Authenticate challenges and 413 problems in one place, fetch
the principal through one helper, and refuse gate rules that name an
operation missing from the spec.
2026-09-28 20:06:28 -04:00
Deluan
a41e656706 refactor(api): reuse existing helpers in API v1 auth
Use gg.V, slice.Map, chi's RequestSize, core/auth's encryption key and
ClientIPRateLimiter instead of local copies; share the grant idle expiry
constant and a Grant.LastActivity helper; pass last use as a time value.
2026-09-28 20:06:28 -04:00
Deluan
88a652346b docs(api): say a password change ends the user's other Navidrome sessions
changePassword now documents that on Navidrome the change also ends the
user's sessions on its other APIs, regardless of revokeOtherGrants, which
only covers API v1 grants.
2026-09-28 20:06:28 -04:00
Deluan
870942c7dd fix(api): record the full client IP, challenge presented tokens and mark token responses no-store
The gate passed server.ClientIP to Authenticate and ResolveGrant, which
masks IPv6 addresses to their /64 for rate limiting, so lastUsedIp stored
a prefix. A new server.ClientAddr returns the resolved address unmasked;
ClientIP builds on it and stays the rate limiter key.

A 401 raised after a token was accepted by the gate, such as a password
change whose grant was revoked mid-request, carried a bare Bearer
challenge. writeProblemStatus now answers Bearer error="invalid_token"
whenever the request presented a bearer token.

login, setupFirstAdmin and createAccessToken responses now carry
Cache-Control: no-store (RFC 6749 section 5.1), set by the gate for a
small list of operations so their error responses are covered too.

The v0/v1 setup race test also checks the v1 status is 201 or 409.
2026-09-28 20:06:28 -04:00
Deluan
406a2e9cf4 fix(api): never widen the scopes an access token claims
Authenticate ran token claims through Expand, so a token claiming `all`
would have gained every known scope. Only a holder of the signing key
could mint one, but tokens should carry concrete scopes only. Claims now
go through Allowed, which keeps known scopes (and admin only for admins)
and never expands `all`.
2026-09-28 20:06:28 -04:00
Deluan
3e3b73a9cc fix(api): use a 256-bit API v1 signing key
The HS256 key was 22 base62 characters, about 128 bits, below the 256 bits
RFC 7518 section 3.2 asks for. New keys are 32 bytes from crypto/rand,
hex-encoded before being encrypted and stored. Keys already stored keep
working unchanged.
2026-09-28 20:06:28 -04:00
Deluan
b1cfa932be fix(api): make logout idempotent and hide grants left on a stale epoch
Logout answered an undeclared 404 when its grant was already gone, for
example revoked by another node inside the liveness cache window or by a
concurrent logout. It now treats a missing grant as success and still
evicts the cache entry, so logout always answers 200.

Grants left on an older user epoch (after a password reset through the
existing UI, or a login that raced a password change) are dead but only
deleted when presented. Listing and counting grants now filter on the
user's current epoch, so those grants no longer show up.

dropGrant now deletes before evicting, like RevokeGrant, so a concurrent
cache fill cannot re-cache a grant that is being dropped.
2026-09-28 20:06:28 -04:00
Deluan
28d023449d feat(api): report login methods and add GET /capabilities 2026-09-28 20:06:27 -04:00
Deluan
294c1a9a6a feat(api): add API v1 login, setup, token, grant and password endpoints
Adds the seven auth operations to the spec (createAccessToken, listGrants,
revokeGrant and logout in core; login, setupFirstAdmin and changePassword in
the password module), the bearerAuth/grantAuth schemes, and vacuum rules
requiring explicit security and a known x-scope. The strict handlers sit on
core/apiauth and are covered end to end against a real SQLite database.

The first operations with parameters make the generated code import
github.com/oapi-codegen/runtime. An oapi-codegen overlay renames the shared
offset/limit parameter types, since a generated Offset clashes with Ginkgo's
dot-imported Offset in this package's tests; the published spec is unchanged.
2026-09-28 20:06:27 -04:00
Deluan
4f2d350757 fix(api): route the spec gate on chi's exact path and name the scope in every insufficient-scope challenge 2026-09-28 20:06:27 -04:00
Deluan
3e645959f8 feat(api): enforce API v1 security from the spec and harden problem responses 2026-09-28 20:06:27 -04:00
Deluan
71c379ff44 feat(api): add API v1 token checks, grant management and password change 2026-09-28 20:06:27 -04:00
Deluan
e3cf849507 fix(api): retry the signing-key load after a failure and drop the password from issued grants 2026-09-28 20:06:27 -04:00
Deluan
0bbca1b133 feat(api): add API v1 login, setup, grant resolution and token minting 2026-09-28 20:06:27 -04:00
Deluan
8c0ba43e9a fix(api): tolerate small clock skew between nodes on access tokens 2026-09-28 20:06:27 -04:00
Deluan
899cc428fd feat(api): add the API v1 signing key and access-token JWTs 2026-09-28 20:06:27 -04:00
Deluan
7dd8dba12b feat(api): add grant liveness cache 2026-09-28 20:06:27 -04:00
Deluan
21ce7c7115 feat(api): add API v1 scope rules and grant secrets 2026-09-28 20:06:27 -04:00
Deluan
158721ea60 fix(server): create the first admin inside one locked transaction 2026-09-28 20:06:27 -04:00
Deluan
5688283d78 feat(persistence): add PropertyRepository.PutIfAbsent 2026-09-28 20:06:27 -04:00
Deluan
2afe2ffe0a feat(api): add api_grant storage for API v1 grants 2026-09-28 20:06:27 -04:00
91 changed files with 7115 additions and 157 deletions

View file

@ -36,6 +36,14 @@ rules:
- sharing
- radio
- admin
- password
nd-operation-security-required:
description: Every operation declares security explicitly (use [] for public operations).
severity: error
given: $.paths[*][get,put,post,delete,patch]
then:
field: security
function: defined
nd-operation-stability-level-required:
description: Every operation declares its stability level, which the breaking-change gate relies on.
severity: error

View file

@ -3,7 +3,7 @@
"info": {
"title": "Navidrome API",
"version": "1.0.0",
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n",
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /capabilities` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as\n`Authorization: Bearer \u003csecret\u003e`. A revoked grant stops working within one minute at most.\n",
"license": {
"name": "GPL-3.0",
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
@ -18,6 +18,10 @@
{
"name": "server",
"description": "Server discovery and the published OpenAPI document."
},
{
"name": "auth",
"description": "Grants and login methods."
}
],
"paths": {
@ -29,8 +33,9 @@
"tags": [
"server"
],
"security": [],
"summary": "Describe the server",
"description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n",
"description": "Returns the public server description. No authentication required.\nCapability modules are listed by `GET /capabilities`.\n",
"responses": {
"200": {
"description": "Server description.",
@ -48,6 +53,41 @@
}
}
},
"/capabilities": {
"get": {
"operationId": "getCapabilities",
"x-module": "core",
"x-stability-level": "alpha",
"tags": [
"server"
],
"summary": "List implemented capability modules",
"description": "The capability modules this server implements. Any valid grant may read it, whatever its scopes.",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Implemented modules.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Capabilities"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/openapi.json": {
"get": {
"operationId": "getOpenAPISpecJSON",
@ -56,6 +96,7 @@
"tags": [
"server"
],
"security": [],
"summary": "Get the OpenAPI document (JSON)",
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
"responses": {
@ -81,6 +122,56 @@
}
}
},
"/auth/grants": {
"get": {
"operationId": "listGrants",
"x-module": "core",
"x-scope": "read",
"x-stability-level": "alpha",
"tags": [
"auth"
],
"summary": "List my grants",
"description": "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed.",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"$ref": "#/components/parameters/offset"
},
{
"$ref": "#/components/parameters/limit"
}
],
"responses": {
"200": {
"description": "A page of grants.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GrantList"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/openapi.yaml": {
"get": {
"operationId": "getOpenAPISpecYAML",
@ -89,6 +180,7 @@
"tags": [
"server"
],
"security": [],
"summary": "Get the OpenAPI document (YAML)",
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
"responses": {
@ -113,6 +205,262 @@
}
}
}
},
"/auth/grants/{id}": {
"delete": {
"operationId": "revokeGrant",
"x-module": "core",
"x-scope": "read",
"x-stability-level": "alpha",
"tags": [
"auth"
],
"summary": "Revoke one of my grants",
"description": "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404.",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Grant id.",
"schema": {
"type": "string",
"maxLength": 64
}
}
],
"responses": {
"204": {
"description": "Revoked."
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/auth/logout": {
"post": {
"operationId": "logout",
"x-module": "core",
"x-scope": "read",
"x-stability-level": "alpha",
"tags": [
"auth"
],
"summary": "Log out",
"description": "Revokes the grant that made this request.",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Logged out.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LogoutResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/auth/login": {
"post": {
"operationId": "login",
"x-module": "password",
"x-stability-level": "alpha",
"tags": [
"auth"
],
"summary": "Log in with a password",
"description": "Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way.",
"security": [],
"requestBody": {
"description": "The credentials and a description of the client.",
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CredentialsRequest"
}
}
}
},
"responses": {
"200": {
"description": "The new grant.",
"headers": {
"Cache-Control": {
"$ref": "#/components/headers/CacheControlNoStore"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GrantCreated"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"413": {
"$ref": "#/components/responses/PayloadTooLarge"
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/auth/setup": {
"post": {
"operationId": "setupFirstAdmin",
"x-module": "password",
"x-stability-level": "alpha",
"tags": [
"auth"
],
"summary": "Create the first admin",
"description": "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409.",
"security": [],
"requestBody": {
"description": "The credentials and a description of the client.",
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CredentialsRequest"
}
}
}
},
"responses": {
"201": {
"description": "The admin was created.",
"headers": {
"Cache-Control": {
"$ref": "#/components/headers/CacheControlNoStore"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GrantCreated"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"409": {
"$ref": "#/components/responses/Conflict"
},
"413": {
"$ref": "#/components/responses/PayloadTooLarge"
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/auth/password": {
"post": {
"operationId": "changePassword",
"x-module": "password",
"x-scope": "password",
"x-stability-level": "alpha",
"tags": [
"auth"
],
"summary": "Change my password",
"description": "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server.",
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"description": "The current and the new password.",
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasswordChangeRequest"
}
}
}
},
"responses": {
"204": {
"description": "Password changed."
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"409": {
"$ref": "#/components/responses/Conflict"
},
"413": {
"$ref": "#/components/responses/PayloadTooLarge"
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
}
},
"components": {
@ -120,8 +468,7 @@
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "Short-lived access token minted from a device grant. Not yet applied to any operation."
"description": "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`."
}
},
"schemas": {
@ -153,17 +500,23 @@
"description": "True until the first admin user has been created."
},
"loginMethods": {
"type": "array",
"description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.",
"items": {
"type": "string",
"enum": [
"password"
]
}
"$ref": "#/components/schemas/LoginMethods"
}
}
},
"LoginMethods": {
"type": "object",
"description": "Login methods this server accepts, keyed by method. A missing key means the method is not offered.\nKeys are optional on purpose: discovery is read by clients of any version against servers of any\nversion, so new methods are added as new optional keys. Clients ignore keys they do not know.\n",
"properties": {
"password": {
"$ref": "#/components/schemas/PasswordLoginMethod"
}
}
},
"PasswordLoginMethod": {
"type": "object",
"description": "Username and password login (`POST /auth/login`). No settings yet."
},
"Problem": {
"type": "object",
"description": "RFC 9457 problem details, returned for every 4xx and 5xx response.",
@ -187,7 +540,7 @@
},
"detail": {
"type": "string",
"description": "Human-readable explanation specific to this occurrence. Omitted for internal errors."
"description": "Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients."
},
"code": {
"type": "string",
@ -196,12 +549,21 @@
"validation",
"unauthorized",
"forbidden",
"insufficient_scope",
"not_found",
"method_not_allowed",
"setup_complete",
"password_managed_externally",
"payload_too_large",
"rate_limited",
"unavailable",
"internal"
]
},
"referenceId": {
"type": "string",
"description": "Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request."
},
"errors": {
"type": "array",
"description": "Per-field failures. Present only when `code` is `validation`.",
@ -228,6 +590,310 @@
"description": "Why the value was rejected."
}
}
},
"Capabilities": {
"type": "object",
"description": "Capability modules this server implements, keyed by module. Keys are optional; a missing key means the\nmodule is not implemented. New modules are added as new optional keys. These are server facts, not what\nthe calling grant may use.\n",
"properties": {
"core": {
"$ref": "#/components/schemas/CoreCapability"
},
"password": {
"$ref": "#/components/schemas/PasswordCapability"
}
}
},
"CoreCapability": {
"type": "object",
"description": "The mandatory core module.",
"required": [
"version"
],
"properties": {
"version": {
"type": "integer",
"description": "Module version. Bumped only on semantic change."
}
}
},
"PasswordCapability": {
"type": "object",
"description": "The password login module (login, first-admin setup, password change).",
"required": [
"version"
],
"properties": {
"version": {
"type": "integer",
"description": "Module version. Bumped only on semantic change."
}
}
},
"GrantList": {
"type": "object",
"description": "A page of the caller's grants.",
"required": [
"items",
"total",
"offset",
"limit"
],
"properties": {
"items": {
"type": "array",
"description": "Grants on this page, by last use, most recent first; never-used grants last.",
"items": {
"$ref": "#/components/schemas/Grant"
}
},
"total": {
"type": "integer",
"description": "Total number of grants."
},
"offset": {
"type": "integer",
"description": "Zero-based index of the first returned item."
},
"limit": {
"type": "integer",
"description": "Maximum number of items in this page."
}
}
},
"LogoutResponse": {
"type": "object",
"description": "Result of a logout.",
"required": [
"logoutUrl"
],
"properties": {
"logoutUrl": {
"type": "string",
"nullable": true,
"description": "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do."
}
}
},
"CredentialsRequest": {
"type": "object",
"description": "Username, password and client description for a login or first-admin setup.",
"required": [
"username",
"password",
"client"
],
"properties": {
"username": {
"type": "string",
"minLength": 1,
"maxLength": 255,
"description": "Login name."
},
"password": {
"type": "string",
"minLength": 1,
"maxLength": 1024,
"description": "Password."
},
"client": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"description": "Name of the client app."
},
"clientVersion": {
"type": "string",
"maxLength": 32,
"description": "Version of the client app."
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"description": "Label for this grant. Defaults to `client`."
},
"scopes": {
"type": "array",
"maxItems": 32,
"description": "Scopes the grant may hold. Omit for `all`.",
"items": {
"$ref": "#/components/schemas/ScopeRequest"
}
}
}
},
"GrantCreated": {
"type": "object",
"description": "Returned by every login method. The secret is shown only here; store it and never parse it.",
"required": [
"secret",
"grant",
"user"
],
"properties": {
"secret": {
"type": "string",
"maxLength": 512,
"description": "Opaque grant secret. Send it as `Authorization: Bearer \u003csecret\u003e`."
},
"grant": {
"description": "The new grant.",
"allOf": [
{
"$ref": "#/components/schemas/Grant"
}
]
},
"user": {
"description": "The user the grant belongs to.",
"allOf": [
{
"$ref": "#/components/schemas/AuthUser"
}
]
}
}
},
"PasswordChangeRequest": {
"type": "object",
"description": "Change the caller's own password.",
"required": [
"currentPassword",
"newPassword"
],
"properties": {
"currentPassword": {
"type": "string",
"minLength": 1,
"maxLength": 1024,
"description": "The current password."
},
"newPassword": {
"type": "string",
"minLength": 1,
"maxLength": 1024,
"description": "The new password."
},
"revokeOtherGrants": {
"type": "boolean",
"default": true,
"description": "Revoke every other grant of the user. The calling grant always survives. Default true."
}
}
},
"Grant": {
"type": "object",
"description": "A long-lived grant held by one client of one user.",
"required": [
"id",
"name",
"client",
"clientVersion",
"scopes",
"provider",
"createdAt",
"lastUsedAt",
"lastUsedIp",
"current"
],
"properties": {
"id": {
"type": "string",
"description": "Grant id."
},
"name": {
"type": "string",
"description": "Label shown to the user."
},
"client": {
"type": "string",
"description": "Name of the client app that holds the grant."
},
"clientVersion": {
"type": "string",
"nullable": true,
"description": "Version of the client app, when it sent one."
},
"scopes": {
"type": "array",
"description": "Scopes this grant carries.",
"items": {
"$ref": "#/components/schemas/Scope"
}
},
"provider": {
"type": "string",
"description": "How the grant was created, for example `password` or `setup`. Free-form; new values may appear."
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "When the grant was created."
},
"lastUsedAt": {
"type": "string",
"format": "date-time",
"nullable": true,
"description": "When the grant was last used, at a coarse granularity. Null until first use."
},
"lastUsedIp": {
"type": "string",
"nullable": true,
"description": "Client IP of the last use. Null until first use."
},
"current": {
"type": "boolean",
"description": "True for the grant that made this request."
}
}
},
"Scope": {
"type": "string",
"description": "A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on\ngrants and means every scope the user is entitled to, now and in future releases. New scopes may be added.\n",
"enum": [
"all",
"read",
"password"
]
},
"ScopeRequest": {
"type": "string",
"description": "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working.",
"pattern": "^[a-z][a-z-]*(:write)?$",
"maxLength": 64
},
"AuthUser": {
"type": "object",
"description": "The user a grant belongs to.",
"required": [
"id",
"userName",
"name",
"isAdmin",
"passwordChangeable"
],
"properties": {
"id": {
"type": "string",
"description": "User id."
},
"userName": {
"type": "string",
"description": "Login name."
},
"name": {
"type": "string",
"description": "Display name."
},
"isAdmin": {
"type": "boolean",
"description": "Whether the user is an administrator."
},
"passwordChangeable": {
"type": "boolean",
"description": "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false."
}
}
}
},
"responses": {
@ -241,6 +907,21 @@
}
}
},
"Unauthorized": {
"description": "Missing, invalid, or expired credentials.",
"headers": {
"WWW-Authenticate": {
"$ref": "#/components/headers/WWWAuthenticate"
}
},
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"NotModified": {
"description": "Not modified.",
"headers": {
@ -248,14 +929,127 @@
"$ref": "#/components/headers/ETag"
}
}
},
"BadRequest": {
"description": "The request is malformed or fails validation.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"Forbidden": {
"description": "The caller is authenticated but not allowed to do this.",
"headers": {
"WWW-Authenticate": {
"$ref": "#/components/headers/WWWAuthenticate"
}
},
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"NotFound": {
"description": "No such resource or endpoint.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"PayloadTooLarge": {
"description": "The request body is too large (`payload_too_large`).",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"TooManyRequests": {
"description": "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds.",
"headers": {
"Retry-After": {
"description": "Seconds to wait before retrying.",
"schema": {
"type": "integer"
}
}
},
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"Conflict": {
"description": "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
}
},
"parameters": {
"offset": {
"name": "offset",
"in": "query",
"description": "Zero-based index of the first item to return.",
"required": false,
"schema": {
"type": "integer",
"minimum": 0,
"default": 0
}
},
"limit": {
"name": "limit",
"in": "query",
"description": "Maximum number of items to return.",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 2000,
"default": 100
}
}
},
"headers": {
"WWWAuthenticate": {
"description": "RFC 6750 Bearer challenge, for example `Bearer error=\"insufficient_scope\", scope=\"read\"`.",
"schema": {
"type": "string"
}
},
"ETag": {
"description": "Entity tag for `If-None-Match` revalidation.",
"schema": {
"type": "string"
}
},
"CacheControlNoStore": {
"description": "Always `no-store`, because the response carries a secret.",
"schema": {
"type": "string",
"enum": [
"no-store"
]
}
}
}
}

View file

@ -4,7 +4,7 @@ info:
version: 1.0.0
description: |
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
capability modules through `GET /server` and never sniff versions.
capability modules through `GET /capabilities` and never sniff versions.
Enums are open: new values may be added to any enum within v1. Clients must
accept values they do not recognise instead of failing.
@ -15,6 +15,10 @@ info:
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
in its `Allow` header.
Operations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in
`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as
`Authorization: Bearer <secret>`. A revoked grant stops working within one minute at most.
license:
name: GPL-3.0
url: https://www.gnu.org/licenses/gpl-3.0.html
@ -23,6 +27,8 @@ servers:
tags:
- name: server
description: Server discovery and the published OpenAPI document.
- name: auth
description: Grants and login methods.
paths:
/server:
get:
@ -30,11 +36,11 @@ paths:
x-module: core
x-stability-level: alpha
tags: [server]
security: []
summary: Describe the server
description: |
Returns the public server description. No authentication required.
Authenticated requests will additionally receive the implemented capability modules
once authentication is available.
Capability modules are listed by `GET /capabilities`.
responses:
'200':
description: Server description.
@ -44,12 +50,30 @@ paths:
$ref: '#/components/schemas/ServerInfo'
'500':
$ref: '#/components/responses/InternalError'
/capabilities:
get:
operationId: getCapabilities
x-module: core
x-stability-level: alpha
tags: [server]
summary: List implemented capability modules
description: The capability modules this server implements. Any valid grant may read it, whatever its scopes.
security: [{bearerAuth: []}]
responses:
'200':
description: Implemented modules.
content:
application/json:
schema: {$ref: '#/components/schemas/Capabilities'}
'401': {$ref: '#/components/responses/Unauthorized'}
'500': {$ref: '#/components/responses/InternalError'}
/openapi.json:
get:
operationId: getOpenAPISpecJSON
x-module: core
x-stability-level: alpha
tags: [server]
security: []
summary: Get the OpenAPI document (JSON)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:
@ -65,12 +89,41 @@ paths:
description: OpenAPI 3.0 document.
'304':
$ref: '#/components/responses/NotModified'
/auth/grants:
get:
operationId: listGrants
x-module: core
x-scope: read
x-stability-level: alpha
tags: [auth]
summary: List my grants
description: "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed."
security: [{bearerAuth: []}]
parameters:
- $ref: '#/components/parameters/offset'
- $ref: '#/components/parameters/limit'
responses:
'200':
description: A page of grants.
content:
application/json:
schema:
$ref: '#/components/schemas/GrantList'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalError'
/openapi.yaml:
get:
operationId: getOpenAPISpecYAML
x-module: core
x-stability-level: alpha
tags: [server]
security: []
summary: Get the OpenAPI document (YAML)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:
@ -86,13 +139,172 @@ paths:
description: OpenAPI 3.0 document.
'304':
$ref: '#/components/responses/NotModified'
/auth/grants/{id}:
delete:
operationId: revokeGrant
x-module: core
x-scope: read
x-stability-level: alpha
tags: [auth]
summary: Revoke one of my grants
description: "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404."
security: [{bearerAuth: []}]
parameters:
- name: id
in: path
required: true
description: Grant id.
schema:
type: string
maxLength: 64
responses:
'204':
description: Revoked.
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
/auth/logout:
post:
operationId: logout
x-module: core
x-scope: read
x-stability-level: alpha
tags: [auth]
summary: Log out
description: Revokes the grant that made this request.
security: [{bearerAuth: []}]
responses:
'200':
description: Logged out.
content:
application/json:
schema:
$ref: '#/components/schemas/LogoutResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalError'
/auth/login:
post:
operationId: login
x-module: password
x-stability-level: alpha
tags: [auth]
summary: Log in with a password
description: Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way.
security: []
requestBody:
description: The credentials and a description of the client.
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CredentialsRequest'
responses:
'200':
description: The new grant.
headers:
Cache-Control:
$ref: '#/components/headers/CacheControlNoStore'
content:
application/json:
schema:
$ref: '#/components/schemas/GrantCreated'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'413':
$ref: '#/components/responses/PayloadTooLarge'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalError'
/auth/setup:
post:
operationId: setupFirstAdmin
x-module: password
x-stability-level: alpha
tags: [auth]
summary: Create the first admin
description: "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409."
security: []
requestBody:
description: The credentials and a description of the client.
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CredentialsRequest'
responses:
'201':
description: The admin was created.
headers:
Cache-Control:
$ref: '#/components/headers/CacheControlNoStore'
content:
application/json:
schema:
$ref: '#/components/schemas/GrantCreated'
'400':
$ref: '#/components/responses/BadRequest'
'409':
$ref: '#/components/responses/Conflict'
'413':
$ref: '#/components/responses/PayloadTooLarge'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalError'
/auth/password:
post:
operationId: changePassword
x-module: password
x-scope: password
x-stability-level: alpha
tags: [auth]
summary: Change my password
description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server."
security: [{bearerAuth: []}]
requestBody:
description: The current and the new password.
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PasswordChangeRequest'
responses:
'204':
description: Password changed.
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
'413':
$ref: '#/components/responses/PayloadTooLarge'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalError'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`."
schemas:
ServerInfo:
type: object
@ -117,12 +329,19 @@ components:
type: boolean
description: True until the first admin user has been created.
loginMethods:
type: array
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
items:
type: string
enum:
- password
$ref: '#/components/schemas/LoginMethods'
LoginMethods:
type: object
description: |
Login methods this server accepts, keyed by method. A missing key means the method is not offered.
Keys are optional on purpose: discovery is read by clients of any version against servers of any
version, so new methods are added as new optional keys. Clients ignore keys they do not know.
properties:
password:
$ref: '#/components/schemas/PasswordLoginMethod'
PasswordLoginMethod:
type: object
description: Username and password login (`POST /auth/login`). No settings yet.
Problem:
type: object
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
@ -145,7 +364,7 @@ components:
description: HTTP status code of this response.
detail:
type: string
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
description: Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients.
code:
type: string
description: Machine-readable error code, and the value clients switch on. New codes may be added.
@ -153,10 +372,18 @@ components:
- validation
- unauthorized
- forbidden
- insufficient_scope
- not_found
- method_not_allowed
- setup_complete
- password_managed_externally
- payload_too_large
- rate_limited
- unavailable
- internal
referenceId:
type: string
description: Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request.
errors:
type: array
description: Per-field failures. Present only when `code` is `validation`.
@ -175,6 +402,238 @@ components:
message:
type: string
description: Why the value was rejected.
Capabilities:
type: object
description: |
Capability modules this server implements, keyed by module. Keys are optional; a missing key means the
module is not implemented. New modules are added as new optional keys. These are server facts, not what
the calling grant may use.
properties:
core:
$ref: '#/components/schemas/CoreCapability'
password:
$ref: '#/components/schemas/PasswordCapability'
CoreCapability:
type: object
description: The mandatory core module.
required:
- version
properties:
version:
type: integer
description: Module version. Bumped only on semantic change.
PasswordCapability:
type: object
description: The password login module (login, first-admin setup, password change).
required:
- version
properties:
version:
type: integer
description: Module version. Bumped only on semantic change.
GrantList:
type: object
description: "A page of the caller's grants."
required:
- items
- total
- offset
- limit
properties:
items:
type: array
description: "Grants on this page, by last use, most recent first; never-used grants last."
items:
$ref: '#/components/schemas/Grant'
total:
type: integer
description: Total number of grants.
offset:
type: integer
description: Zero-based index of the first returned item.
limit:
type: integer
description: Maximum number of items in this page.
LogoutResponse:
type: object
description: Result of a logout.
required:
- logoutUrl
properties:
logoutUrl:
type: string
nullable: true
description: "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do."
CredentialsRequest:
type: object
description: "Username, password and client description for a login or first-admin setup."
required:
- username
- password
- client
properties:
username:
type: string
minLength: 1
maxLength: 255
description: Login name.
password:
type: string
minLength: 1
maxLength: 1024
description: Password.
client:
type: string
minLength: 1
maxLength: 64
description: Name of the client app.
clientVersion:
type: string
maxLength: 32
description: Version of the client app.
name:
type: string
minLength: 1
maxLength: 64
description: "Label for this grant. Defaults to `client`."
scopes:
type: array
maxItems: 32
description: "Scopes the grant may hold. Omit for `all`."
items:
$ref: '#/components/schemas/ScopeRequest'
GrantCreated:
type: object
description: "Returned by every login method. The secret is shown only here; store it and never parse it."
required:
- secret
- grant
- user
properties:
secret:
type: string
maxLength: 512
description: "Opaque grant secret. Send it as `Authorization: Bearer <secret>`."
grant:
description: The new grant.
allOf:
- $ref: '#/components/schemas/Grant'
user:
description: The user the grant belongs to.
allOf:
- $ref: '#/components/schemas/AuthUser'
PasswordChangeRequest:
type: object
description: "Change the caller's own password."
required:
- currentPassword
- newPassword
properties:
currentPassword:
type: string
minLength: 1
maxLength: 1024
description: The current password.
newPassword:
type: string
minLength: 1
maxLength: 1024
description: The new password.
revokeOtherGrants:
type: boolean
default: true
description: "Revoke every other grant of the user. The calling grant always survives. Default true."
Grant:
type: object
description: A long-lived grant held by one client of one user.
required:
- id
- name
- client
- clientVersion
- scopes
- provider
- createdAt
- lastUsedAt
- lastUsedIp
- current
properties:
id:
type: string
description: Grant id.
name:
type: string
description: Label shown to the user.
client:
type: string
description: Name of the client app that holds the grant.
clientVersion:
type: string
nullable: true
description: "Version of the client app, when it sent one."
scopes:
type: array
description: Scopes this grant carries.
items:
$ref: '#/components/schemas/Scope'
provider:
type: string
description: "How the grant was created, for example `password` or `setup`. Free-form; new values may appear."
createdAt:
type: string
format: date-time
description: When the grant was created.
lastUsedAt:
type: string
format: date-time
nullable: true
description: "When the grant was last used, at a coarse granularity. Null until first use."
lastUsedIp:
type: string
nullable: true
description: Client IP of the last use. Null until first use.
current:
type: boolean
description: True for the grant that made this request.
Scope:
type: string
description: |
A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on
grants and means every scope the user is entitled to, now and in future releases. New scopes may be added.
enum:
- all
- read
- password
ScopeRequest:
type: string
description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working."
pattern: '^[a-z][a-z-]*(:write)?$'
maxLength: 64
AuthUser:
type: object
description: The user a grant belongs to.
required:
- id
- userName
- name
- isAdmin
- passwordChangeable
properties:
id:
type: string
description: User id.
userName:
type: string
description: Login name.
name:
type: string
description: Display name.
isAdmin:
type: boolean
description: Whether the user is an administrator.
passwordChangeable:
type: boolean
description: "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false."
responses:
InternalError:
description: Unexpected server failure. Details are in the server log.
@ -182,13 +641,96 @@ components:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
Unauthorized:
description: Missing, invalid, or expired credentials.
headers:
WWW-Authenticate:
$ref: '#/components/headers/WWWAuthenticate'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
NotModified:
description: Not modified.
headers:
ETag:
$ref: '#/components/headers/ETag'
BadRequest:
description: The request is malformed or fails validation.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
Forbidden:
description: The caller is authenticated but not allowed to do this.
headers:
WWW-Authenticate:
$ref: '#/components/headers/WWWAuthenticate'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
NotFound:
description: No such resource or endpoint.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
PayloadTooLarge:
description: "The request body is too large (`payload_too_large`)."
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
TooManyRequests:
description: "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds."
headers:
Retry-After:
description: Seconds to wait before retrying.
schema:
type: integer
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
Conflict:
description: "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`."
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
parameters:
offset:
name: offset
in: query
description: Zero-based index of the first item to return.
required: false
schema:
type: integer
minimum: 0
default: 0
limit:
name: limit
in: query
description: Maximum number of items to return.
required: false
schema:
type: integer
minimum: 1
maximum: 2000
default: 100
headers:
WWWAuthenticate:
description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.'
schema:
type: string
ETag:
description: Entity tag for `If-None-Match` revalidation.
schema:
type: string
CacheControlNoStore:
description: Always `no-store`, because the response carries a secret.
schema:
type: string
enum:
- no-store

View file

@ -0,0 +1,4 @@
description: Always `no-store`, because the response carries a secret.
schema:
type: string
enum: [no-store]

View file

@ -0,0 +1,3 @@
description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.'
schema:
type: string

View file

@ -0,0 +1,5 @@
description: "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`."
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,4 +1,7 @@
description: The caller is authenticated but not allowed to do this.
headers:
WWW-Authenticate:
$ref: ../headers/WWWAuthenticate.yaml
content:
application/problem+json:
schema:

View file

@ -0,0 +1,5 @@
description: "The request body is too large (`payload_too_large`)."
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -0,0 +1,10 @@
description: "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds."
headers:
Retry-After:
description: Seconds to wait before retrying.
schema:
type: integer
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,4 +1,7 @@
description: Missing, invalid, or expired credentials.
headers:
WWW-Authenticate:
$ref: ../headers/WWWAuthenticate.yaml
content:
application/problem+json:
schema:

View file

@ -0,0 +1,19 @@
type: object
description: The user a grant belongs to.
required: [id, userName, name, isAdmin, passwordChangeable]
properties:
id:
type: string
description: User id.
userName:
type: string
description: Login name.
name:
type: string
description: Display name.
isAdmin:
type: boolean
description: Whether the user is an administrator.
passwordChangeable:
type: boolean
description: "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false."

View file

@ -0,0 +1,10 @@
type: object
description: |
Capability modules this server implements, keyed by module. Keys are optional; a missing key means the
module is not implemented. New modules are added as new optional keys. These are server facts, not what
the calling grant may use.
properties:
core:
$ref: ./CoreCapability.yaml
password:
$ref: ./PasswordCapability.yaml

View file

@ -0,0 +1,5 @@
type: object
description: The mandatory core module.
required: [version]
properties:
version: {type: integer, description: Module version. Bumped only on semantic change.}

View file

@ -0,0 +1,34 @@
type: object
description: "Username, password and client description for a login or first-admin setup."
required: [username, password, client]
properties:
username:
type: string
minLength: 1
maxLength: 255
description: Login name.
password:
type: string
minLength: 1
maxLength: 1024
description: Password.
client:
type: string
minLength: 1
maxLength: 64
description: Name of the client app.
clientVersion:
type: string
maxLength: 32
description: Version of the client app.
name:
type: string
minLength: 1
maxLength: 64
description: "Label for this grant. Defaults to `client`."
scopes:
type: array
maxItems: 32
description: "Scopes the grant may hold. Omit for `all`."
items:
$ref: ./ScopeRequest.yaml

View file

@ -0,0 +1,41 @@
type: object
description: A long-lived grant held by one client of one user.
required: [id, name, client, clientVersion, scopes, provider, createdAt, lastUsedAt, lastUsedIp, current]
properties:
id:
type: string
description: Grant id.
name:
type: string
description: Label shown to the user.
client:
type: string
description: Name of the client app that holds the grant.
clientVersion:
type: string
nullable: true
description: "Version of the client app, when it sent one."
scopes:
type: array
description: Scopes this grant carries.
items:
$ref: ./Scope.yaml
provider:
type: string
description: "How the grant was created, for example `password` or `setup`. Free-form; new values may appear."
createdAt:
type: string
format: date-time
description: When the grant was created.
lastUsedAt:
type: string
format: date-time
nullable: true
description: "When the grant was last used, at a coarse granularity. Null until first use."
lastUsedIp:
type: string
nullable: true
description: Client IP of the last use. Null until first use.
current:
type: boolean
description: True for the grant that made this request.

View file

@ -0,0 +1,16 @@
type: object
description: "Returned by every login method. The secret is shown only here; store it and never parse it."
required: [secret, grant, user]
properties:
secret:
type: string
maxLength: 512
description: "Opaque grant secret. Send it as `Authorization: Bearer <secret>`."
grant:
description: The new grant.
allOf:
- $ref: ./Grant.yaml
user:
description: The user the grant belongs to.
allOf:
- $ref: ./AuthUser.yaml

View file

@ -0,0 +1,18 @@
type: object
description: "A page of the caller's grants."
required: [items, total, offset, limit]
properties:
items:
type: array
description: "Grants on this page, by last use, most recent first; never-used grants last."
items:
$ref: ./Grant.yaml
total:
type: integer
description: Total number of grants.
offset:
type: integer
description: Zero-based index of the first returned item.
limit:
type: integer
description: Maximum number of items in this page.

View file

@ -0,0 +1,8 @@
type: object
description: |
Login methods this server accepts, keyed by method. A missing key means the method is not offered.
Keys are optional on purpose: discovery is read by clients of any version against servers of any
version, so new methods are added as new optional keys. Clients ignore keys they do not know.
properties:
password:
$ref: ./PasswordLoginMethod.yaml

View file

@ -0,0 +1,8 @@
type: object
description: Result of a logout.
required: [logoutUrl]
properties:
logoutUrl:
type: string
nullable: true
description: "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do."

View file

@ -0,0 +1,5 @@
type: object
description: The password login module (login, first-admin setup, password change).
required: [version]
properties:
version: {type: integer, description: Module version. Bumped only on semantic change.}

View file

@ -0,0 +1,18 @@
type: object
description: "Change the caller's own password."
required: [currentPassword, newPassword]
properties:
currentPassword:
type: string
minLength: 1
maxLength: 1024
description: The current password.
newPassword:
type: string
minLength: 1
maxLength: 1024
description: The new password.
revokeOtherGrants:
type: boolean
default: true
description: "Revoke every other grant of the user. The calling grant always survives. Default true."

View file

@ -0,0 +1,2 @@
type: object
description: Username and password login (`POST /auth/login`). No settings yet.

View file

@ -16,7 +16,7 @@ properties:
description: HTTP status code of this response.
detail:
type: string
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
description: Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients.
code:
type: string
description: Machine-readable error code, and the value clients switch on. New codes may be added.
@ -24,10 +24,18 @@ properties:
- validation
- unauthorized
- forbidden
- insufficient_scope
- not_found
- method_not_allowed
- setup_complete
- password_managed_externally
- payload_too_large
- rate_limited
- unavailable
- internal
referenceId:
type: string
description: Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request.
errors:
type: array
description: Per-field failures. Present only when `code` is `validation`.

View file

@ -0,0 +1,5 @@
type: string
description: |
A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on
grants and means every scope the user is entitled to, now and in future releases. New scopes may be added.
enum: [all, read, password]

View file

@ -0,0 +1,4 @@
type: string
description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working."
pattern: '^[a-z][a-z-]*(:write)?$'
maxLength: 64

View file

@ -15,8 +15,4 @@ properties:
type: boolean
description: True until the first admin user has been created.
loginMethods:
type: array
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
items:
type: string
enum: [password]
$ref: ./LoginMethods.yaml

View file

@ -4,7 +4,7 @@ info:
version: 1.0.0
description: |
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
capability modules through `GET /server` and never sniff versions.
capability modules through `GET /capabilities` and never sniff versions.
Enums are open: new values may be added to any enum within v1. Clients must
accept values they do not recognise instead of failing.
@ -15,6 +15,10 @@ info:
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
in its `Allow` header.
Operations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in
`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as
`Authorization: Bearer <secret>`. A revoked grant stops working within one minute at most.
license:
name: GPL-3.0
url: https://www.gnu.org/licenses/gpl-3.0.html
@ -23,17 +27,32 @@ servers:
tags:
- name: server
description: Server discovery and the published OpenAPI document.
- name: auth
description: Grants and login methods.
paths:
/server:
$ref: ./paths/server.yaml
/capabilities:
$ref: ./paths/capabilities.yaml
/openapi.json:
$ref: ./paths/openapi.yaml#/json
/openapi.yaml:
$ref: ./paths/openapi.yaml#/yaml
/auth/grants:
$ref: ./paths/auth.yaml#/grants
/auth/grants/{id}:
$ref: ./paths/auth.yaml#/grant
/auth/logout:
$ref: ./paths/auth.yaml#/logout
/auth/login:
$ref: ./paths/auth.yaml#/login
/auth/setup:
$ref: ./paths/auth.yaml#/setup
/auth/password:
$ref: ./paths/auth.yaml#/password
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`."

188
api/openapi/paths/auth.yaml Normal file
View file

@ -0,0 +1,188 @@
grants:
get:
operationId: listGrants
x-module: core
x-scope: read
x-stability-level: alpha
tags: [auth]
summary: List my grants
description: "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed."
security: [{bearerAuth: []}]
parameters:
- $ref: ../components/parameters/offset.yaml
- $ref: ../components/parameters/limit.yaml
responses:
'200':
description: A page of grants.
content:
application/json:
schema:
$ref: ../components/schemas/GrantList.yaml
'400':
$ref: ../components/responses/BadRequest.yaml
'401':
$ref: ../components/responses/Unauthorized.yaml
'403':
$ref: ../components/responses/Forbidden.yaml
'500':
$ref: ../components/responses/InternalError.yaml
grant:
delete:
operationId: revokeGrant
x-module: core
x-scope: read
x-stability-level: alpha
tags: [auth]
summary: Revoke one of my grants
description: "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404."
security: [{bearerAuth: []}]
parameters:
- name: id
in: path
required: true
description: Grant id.
schema:
type: string
maxLength: 64
responses:
'204':
description: Revoked.
'400':
$ref: ../components/responses/BadRequest.yaml
'401':
$ref: ../components/responses/Unauthorized.yaml
'403':
$ref: ../components/responses/Forbidden.yaml
'404':
$ref: ../components/responses/NotFound.yaml
'500':
$ref: ../components/responses/InternalError.yaml
logout:
post:
operationId: logout
x-module: core
x-scope: read
x-stability-level: alpha
tags: [auth]
summary: Log out
description: Revokes the grant that made this request.
security: [{bearerAuth: []}]
responses:
'200':
description: Logged out.
content:
application/json:
schema:
$ref: ../components/schemas/LogoutResponse.yaml
'401':
$ref: ../components/responses/Unauthorized.yaml
'403':
$ref: ../components/responses/Forbidden.yaml
'500':
$ref: ../components/responses/InternalError.yaml
login:
post:
operationId: login
x-module: password
x-stability-level: alpha
tags: [auth]
summary: Log in with a password
description: Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way.
security: []
requestBody:
description: The credentials and a description of the client.
required: true
content:
application/json:
schema:
$ref: ../components/schemas/CredentialsRequest.yaml
responses:
'200':
description: The new grant.
headers:
Cache-Control:
$ref: ../components/headers/CacheControlNoStore.yaml
content:
application/json:
schema:
$ref: ../components/schemas/GrantCreated.yaml
'400':
$ref: ../components/responses/BadRequest.yaml
'401':
$ref: ../components/responses/Unauthorized.yaml
'413':
$ref: ../components/responses/PayloadTooLarge.yaml
'429':
$ref: ../components/responses/TooManyRequests.yaml
'500':
$ref: ../components/responses/InternalError.yaml
setup:
post:
operationId: setupFirstAdmin
x-module: password
x-stability-level: alpha
tags: [auth]
summary: Create the first admin
description: "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409."
security: []
requestBody:
description: The credentials and a description of the client.
required: true
content:
application/json:
schema:
$ref: ../components/schemas/CredentialsRequest.yaml
responses:
'201':
description: The admin was created.
headers:
Cache-Control:
$ref: ../components/headers/CacheControlNoStore.yaml
content:
application/json:
schema:
$ref: ../components/schemas/GrantCreated.yaml
'400':
$ref: ../components/responses/BadRequest.yaml
'409':
$ref: ../components/responses/Conflict.yaml
'413':
$ref: ../components/responses/PayloadTooLarge.yaml
'429':
$ref: ../components/responses/TooManyRequests.yaml
'500':
$ref: ../components/responses/InternalError.yaml
password:
post:
operationId: changePassword
x-module: password
x-scope: password
x-stability-level: alpha
tags: [auth]
summary: Change my password
description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server."
security: [{bearerAuth: []}]
requestBody:
description: The current and the new password.
required: true
content:
application/json:
schema:
$ref: ../components/schemas/PasswordChangeRequest.yaml
responses:
'204':
description: Password changed.
'400':
$ref: ../components/responses/BadRequest.yaml
'401':
$ref: ../components/responses/Unauthorized.yaml
'403':
$ref: ../components/responses/Forbidden.yaml
'409':
$ref: ../components/responses/Conflict.yaml
'413':
$ref: ../components/responses/PayloadTooLarge.yaml
'429':
$ref: ../components/responses/TooManyRequests.yaml
'500':
$ref: ../components/responses/InternalError.yaml

View file

@ -0,0 +1,16 @@
get:
operationId: getCapabilities
x-module: core
x-stability-level: alpha
tags: [server]
summary: List implemented capability modules
description: The capability modules this server implements. Any valid grant may read it, whatever its scopes.
security: [{bearerAuth: []}]
responses:
'200':
description: Implemented modules.
content:
application/json:
schema: {$ref: ../components/schemas/Capabilities.yaml}
'401': {$ref: ../components/responses/Unauthorized.yaml}
'500': {$ref: ../components/responses/InternalError.yaml}

View file

@ -4,6 +4,7 @@ json:
x-module: core
x-stability-level: alpha
tags: [server]
security: []
summary: Get the OpenAPI document (JSON)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:
@ -25,6 +26,7 @@ yaml:
x-module: core
x-stability-level: alpha
tags: [server]
security: []
summary: Get the OpenAPI document (YAML)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:

View file

@ -3,11 +3,11 @@ get:
x-module: core
x-stability-level: alpha
tags: [server]
security: []
summary: Describe the server
description: |
Returns the public server description. No authentication required.
Authenticated requests will additionally receive the implemented capability modules
once authentication is available.
Capability modules are listed by `GET /capabilities`.
responses:
'200':
description: Server description.

View file

@ -34,6 +34,7 @@ const (
JWTPublicSecretKey = "JWTPublicSecret"
JWTIssuer = "ND"
DefaultSessionTimeout = 48 * time.Hour
APIv1GrantIdleExpiry = 90 * 24 * time.Hour
DefaultSmartRefresh = 5 * time.Second
DefaultShareExpiration = 8760 * time.Hour
CookieExpiry = 365 * 24 * 3600 // One year

View file

@ -3,6 +3,7 @@ package agents
import (
"context"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
@ -13,6 +14,7 @@ type SessionKeys struct {
}
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
ctx = log.WithSecrets(ctx, sessionKey)
return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey)
}

View file

@ -1,21 +1,31 @@
package agents
import (
"bytes"
"context"
"database/sql"
"os"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/tests"
"github.com/pocketbase/dbx"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("SessionKeys", func() {
ctx := context.Background()
var ctx context.Context
user := model.User{ID: "u-1"}
ds := &tests.MockDataStore{MockedUserProps: &tests.MockedUserPropsRepo{}}
sk := SessionKeys{DataStore: ds, KeyName: "fakeSessionKey"}
BeforeEach(func() {
ctx = GinkgoT().Context()
})
It("uses the assigned key name", func() {
Expect(sk.KeyName).To(Equal("fakeSessionKey"))
})
@ -34,4 +44,34 @@ var _ = Describe("SessionKeys", func() {
_, err := sk.Get(ctx, "u-2")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("never logs the session key, but still logs the user id and key name", func() {
conn, err := sql.Open("sqlite3", ":memory:")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(conn.Close)
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(ctx, "create table user_props (user_id varchar, key varchar, value varchar)")
Expect(err).ToNot(HaveOccurred())
props := persistence.NewUserPropsRepository(dbx.NewFromDB(conn, "sqlite3"))
dbKeys := SessionKeys{DataStore: &tests.MockDataStore{MockedUserProps: props}, KeyName: "LastFMSessionKey"}
logs := &bytes.Buffer{}
log.SetOutput(logs)
log.SetLevel(log.LevelTrace)
DeferCleanup(func() {
log.SetOutput(os.Stderr)
log.SetLevel(log.LevelFatal)
})
Expect(dbKeys.Put(ctx, "logged-user-id", "inserted-session-key")).To(Succeed())
Expect(dbKeys.Put(ctx, "logged-user-id", "updated-session-key")).To(Succeed())
Expect(dbKeys.Get(ctx, "logged-user-id")).To(Equal("updated-session-key"))
Expect(logs.String()).To(ContainSubstring("INSERT INTO user_props"))
Expect(logs.String()).To(ContainSubstring("UPDATE user_props"))
Expect(logs.String()).To(ContainSubstring("logged-user-id"))
Expect(logs.String()).To(ContainSubstring("LastFMSessionKey"))
Expect(logs.String()).ToNot(ContainSubstring("inserted-session-key"))
Expect(logs.String()).ToNot(ContainSubstring("updated-session-key"))
})
})

View file

@ -0,0 +1,17 @@
package apiauth
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestAPIAuth(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "API Auth Suite")
}

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))
})
})

43
core/apiauth/db_test.go Normal file
View file

@ -0,0 +1,43 @@
package apiauth
import (
"context"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/persistence"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var realDS model.DataStore
// One database for the whole suite: db.Db() is a process-wide singleton.
var _ = BeforeSuite(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "apiauth.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
DeferCleanup(db.Init(GinkgoT().Context()))
realDS = persistence.New(db.Db())
})
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}
ExpectWithOffset(1, realDS.User().Put(ctx, &u)).To(Succeed())
stored, err := realDS.User().FindByUsername(ctx, name)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return *stored
}
// login signs u in (nil scopes asks for all) and authenticates with the new grant secret.
func login(ctx context.Context, svc *Service, u model.User, password string, scopes []string) (*Issued, *Principal) {
issued, err := svc.Login(ctx, u.UserName, password, meta, scopes)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
p, err := svc.Authenticate(ctx, issued.Secret, "")
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return issued, p
}

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 }

63
core/apiauth/scopes.go Normal file
View file

@ -0,0 +1,63 @@
package apiauth
import (
"slices"
"strings"
)
const (
ScopeAll = "all"
ScopeRead = "read"
ScopePassword = "password"
ScopeAdmin = "admin"
)
// KnownScopes lists the scopes of modules this server implements; `all` expands to these.
var KnownScopes = []string{ScopeRead, ScopePassword}
func known(s string) bool {
return slices.Contains(KnownScopes, s)
}
func grantable(s string, isAdmin bool) bool {
return known(s) && (s != ScopeAdmin || isAdmin)
}
func normalize(in []string) []string {
out := slices.Clone(in)
slices.Sort(out)
return slices.Compact(out)
}
// Entitled returns the scopes a new grant stores.
func Entitled(requested []string, isAdmin bool) []string {
if requested == nil {
return []string{ScopeAll}
}
var out []string
for _, s := range requested {
if s == ScopeAll || grantable(s, isAdmin) {
out = append(out, s)
}
}
return normalize(out)
}
// Expand turns a grant's stored scopes into the concrete scopes it carries right now.
func Expand(granted []string, isAdmin bool) []string {
var out []string
for _, s := range granted {
if s == ScopeAll {
out = append(out, KnownScopes...)
continue
}
out = append(out, s)
}
out = slices.DeleteFunc(out, func(s string) bool { return !grantable(s, isAdmin) })
return normalize(out)
}
func Satisfies(scopes []string, required string) bool {
return slices.Contains(scopes, required) ||
(!strings.HasSuffix(required, ":write") && slices.Contains(scopes, required+":write"))
}

View file

@ -0,0 +1,50 @@
package apiauth
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("scopes", func() {
BeforeEach(func() {
saved := KnownScopes
KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin, "playlists", "playlists:write"}
DeferCleanup(func() { KnownScopes = saved })
})
Describe("Entitled", func() {
It("stores all when nothing is requested", func() {
Expect(Entitled(nil, false)).To(Equal([]string{ScopeAll}))
})
It("drops unknown scopes and admin for non-admins", func() {
Expect(Entitled([]string{"read", "future", "admin"}, false)).To(Equal([]string{"read"}))
})
It("keeps admin for admins and keeps all", func() {
Expect(Entitled([]string{"admin", "all"}, true)).To(Equal([]string{"admin", "all"}))
})
})
Describe("Expand", func() {
It("replaces all with every known scope except admin for non-admins", func() {
Expect(Expand([]string{ScopeAll}, false)).To(Equal([]string{"password", "playlists", "playlists:write", "read"}))
})
It("includes admin for admins", func() {
Expect(Expand([]string{ScopeAll}, true)).To(ContainElement("admin"))
})
It("drops admin from explicit scopes when the user is no longer an admin", func() {
Expect(Expand([]string{"admin", "read"}, false)).To(Equal([]string{"read"}))
})
It("drops scopes that are no longer known", func() {
Expect(Expand([]string{"read", "retired"}, false)).To(Equal([]string{"read"}))
})
})
Describe("Satisfies", func() {
It("accepts the exact scope or its :write form", func() {
Expect(Satisfies([]string{"read"}, "read")).To(BeTrue())
Expect(Satisfies([]string{"playlists:write"}, "playlists")).To(BeTrue())
Expect(Satisfies([]string{"playlists"}, "playlists:write")).To(BeFalse())
Expect(Satisfies(nil, "read")).To(BeFalse())
})
})
})

20
core/apiauth/secret.go Normal file
View file

@ -0,0 +1,20 @@
package apiauth
import (
"crypto/sha256"
"encoding/hex"
"github.com/navidrome/navidrome/model/id"
)
const secretPrefix = "ndg_"
func newSecret() (secret, hash string) {
secret = secretPrefix + id.NewRandom()
return secret, hashSecret(secret)
}
func hashSecret(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}

View file

@ -0,0 +1,23 @@
package apiauth
import (
"regexp"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("grant secrets", func() {
It("are ndg_ plus 22 base62 characters, hashed as hex SHA-256", func() {
secret, hash := newSecret()
Expect(secret).To(MatchRegexp(`^ndg_[0-9A-Za-z]{22}$`))
Expect(hash).To(MatchRegexp(`^[0-9a-f]{64}$`))
Expect(hashSecret(secret)).To(Equal(hash))
})
It("are unique", func() {
a, _ := newSecret()
b, _ := newSecret()
Expect(a).ToNot(Equal(b))
Expect(regexp.MustCompile(`^ndg_`).MatchString(a)).To(BeTrue())
})
})

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

@ -0,0 +1,264 @@
package apiauth
import (
"cmp"
"context"
"errors"
"fmt"
"time"
"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/utils/gg"
)
const (
IdleExpiry = consts.APIv1GrantIdleExpiry
touchInterval = 5 * time.Minute
)
var (
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 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
now func() time.Time
}
func New(ds model.DataStore) *Service {
return &Service{
ds: ds,
checkers: func(ds model.DataStore) []CredentialChecker {
return []CredentialChecker{dbChecker{ds: ds}}
},
now: time.Now,
}
}
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 := auth.CreateFirstAdmin(ctx, s.ds, username, password, func(tx model.DataStore, u *model.User) error {
var err error
issued, err = s.issue(ctx, tx, *u, "setup", meta, scopes)
return err
})
if err != nil {
return nil, err
}
return issued, nil
}
// 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) {
u.Password = ""
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) Authenticate(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
}
if idleSince := s.now().Add(-IdleExpiry); g.LastActivity().Before(idleSince) {
s.dropIdle(ctx, g.ID, idleSince)
return nil, model.ErrInvalidAuth
}
u, err := s.ds.User().Get(ctx, g.UserID)
if errors.Is(err, model.ErrNotFound) {
return nil, model.ErrInvalidAuth
}
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, ip)
return &Principal{User: *u, GrantID: g.ID, Scopes: Expand(g.Scopes, u.IsAdmin)}, nil
}
// dropIdle deletes only still-idle grants, sparing one renewed meanwhile.
func (s *Service) dropIdle(ctx context.Context, id string, idleSince time.Time) {
if _, err := s.ds.Grant().DeleteIdle(ctx, idleSince); err != nil {
log.Warn(ctx, "API v1: could not delete idle grants", "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. Deleting below the snapshot's epoch is safe: a later change only moves kept grants up.
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) {
return nil, nil, model.ErrInvalidAuth
}
if err != nil {
return nil, nil, err
}
if g.UserEpoch != u.TokenEpoch {
if err := s.ds.Grant().DeleteStaleEpochs(ctx, u.ID, u.TokenEpoch); err != nil {
log.Warn(ctx, "API v1: could not delete the user's grants from older epochs", "user", u.ID, "grant", grantID, err)
}
return nil, nil, model.ErrInvalidAuth
}
return g, u, nil
}
// touch writes last_used at most every touchInterval (zero lastUsed: never used); the SQL condition holds that across nodes.
func (s *Service) touch(ctx context.Context, g *model.Grant, ip string) {
now := s.now()
if lastUsed := gg.V(g.LastUsedAt); !lastUsed.IsZero() && now.Before(lastUsed.Add(touchInterval)) {
return
}
if err := s.ds.Grant().Touch(ctx, g.ID, ip, now, now.Add(-touchInterval)); err != nil {
log.Warn(ctx, "API v1: could not record grant use", "grant", g.ID, err)
}
}
// ListGrants shows only the current epoch: grants left on an older one are dead but only deleted when presented.
func (s *Service) ListGrants(ctx context.Context, p *Principal, offset, limit int) (model.Grants, int64, error) {
idleSince := s.now().Add(-IdleExpiry)
grants, err := s.ds.Grant().GetAllForUser(ctx, p.User.ID, p.User.TokenEpoch, idleSince, offset, limit)
if err != nil {
return nil, 0, err
}
total, err := s.ds.Grant().CountForUser(ctx, p.User.ID, p.User.TokenEpoch, idleSince)
return grants, total, err
}
func (s *Service) RevokeGrant(ctx context.Context, p *Principal, grantID string) error {
return s.ds.Grant().DeleteForUser(ctx, p.User.ID, grantID)
}
// Logout succeeds when the grant is already gone, e.g. revoked by another node or a concurrent logout.
func (s *Service) Logout(ctx context.Context, p *Principal) error {
err := s.RevokeGrant(ctx, p, p.GrantID)
if errors.Is(err, model.ErrNotFound) {
return nil
}
return err
}
// ChangePassword does every check inside the locked transaction, so a reset that lands first is never overwritten.
func (s *Service) ChangePassword(ctx context.Context, p *Principal, current, newPassword string, revokeOthers bool) error {
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
u, err := tx.User().Get(ctx, p.User.ID)
if errors.Is(err, model.ErrNotFound) {
return model.ErrInvalidAuth
}
if err != nil {
return err
}
g, err := tx.Grant().Get(ctx, p.GrantID)
if errors.Is(err, model.ErrNotFound) {
return model.ErrInvalidAuth
}
if err != nil {
return err
}
if g.UserID != u.ID || g.UserEpoch != u.TokenEpoch {
return model.ErrInvalidAuth
}
if !PasswordChangeable(*u) {
return model.ErrNotAuthorized
}
res, err := checkCredentials(ctx, s.checkers(tx), u.UserName, current)
if errors.Is(err, model.ErrInvalidAuth) {
return ErrCurrentPasswordMismatch
}
if err != nil {
return err
}
if !res.PasswordLocal {
return ErrPasswordManagedExternally
}
oldEpoch := u.TokenEpoch
u.NewPassword = newPassword
if err := tx.User().Put(ctx, u); err != nil {
return err
}
updated, err := tx.User().Get(ctx, u.ID)
if err != nil {
return err
}
keep := ""
if revokeOthers {
keep = p.GrantID
}
if err := tx.Grant().SetEpoch(ctx, u.ID, oldEpoch, updated.TokenEpoch, keep); err != nil {
return err
}
return tx.Grant().DeleteStaleEpochs(ctx, u.ID, updated.TokenEpoch)
})
}

View file

@ -0,0 +1,463 @@
package apiauth
import (
"context"
"errors"
"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/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var meta = ClientMeta{Name: "Living room", Client: "TestApp", ClientVersion: "1.0"}
var _ = Describe("Service", 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.User.Password).To(BeEmpty())
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("Authenticate", func() {
It("resolves the secret to its user and the grant's expanded scopes", func() {
u := createUser(ctx, "pw", false)
issued, p := login(ctx, svc, u, "pw", nil)
Expect(p.User.ID).To(Equal(u.ID))
Expect(p.GrantID).To(Equal(issued.Grant.ID))
Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))
})
It("carries only the scopes stored on a narrow grant", func() {
u := createUser(ctx, "pw", false)
_, p := login(ctx, svc, u, "pw", []string{ScopePassword})
Expect(p.Scopes).To(Equal([]string{ScopePassword}))
})
It("records the first use with the client IP", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
_, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.9")
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("records use again only after the touch interval", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
_, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.1")
Expect(err).ToNot(HaveOccurred())
now = now.Add(touchInterval - time.Second)
_, err = svc.Authenticate(ctx, issued.Secret, "10.0.0.2")
Expect(err).ToNot(HaveOccurred())
g, _ := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(g.LastUsedIP).To(Equal("10.0.0.1"))
now = now.Add(2 * time.Second)
_, err = svc.Authenticate(ctx, issued.Secret, "10.0.0.3")
Expect(err).ToNot(HaveOccurred())
g, _ = realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(g.LastUsedIP).To(Equal("10.0.0.3"))
Expect(g.LastUsedAt.Equal(now)).To(BeTrue())
})
It("rejects unknown secrets", func() {
_, err := svc.Authenticate(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.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("keeps an idle grant that a concurrent request renewed before the delete ran", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
renewedAt := now.Add(IdleExpiry - time.Minute)
racing := New(hookDS{DataStore: realDS, beforeDeleteIdle: func() {
Expect(realDS.Grant().Touch(ctx, issued.Grant.ID, "10.0.0.2", renewedAt, renewedAt)).To(Succeed())
}})
now = now.Add(IdleExpiry + time.Second)
racing.SetClock(func() time.Time { return now })
_, err := racing.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
g, err := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).ToNot(HaveOccurred())
Expect(g.LastUsedIP).To(Equal("10.0.0.2"))
})
It("rejects and deletes 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.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("does not delete a kept grant when the password changed between reading the grant and the user", func() {
u := createUser(ctx, "pw", false)
issued, p := login(ctx, svc, u, "pw", nil)
racing := New(hookDS{DataStore: realDS, afterFind: func() {
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed())
}})
racing.SetClock(func() time.Time { return now })
_, err := racing.Authenticate(ctx, issued.Secret, "")
Expect(err).ToNot(HaveOccurred())
_, err = realDS.Grant().Get(ctx, p.GrantID)
Expect(err).ToNot(HaveOccurred())
})
It("drops admin from a grant once its user is no longer an admin", func() {
saved := KnownScopes
KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin}
DeferCleanup(func() { KnownScopes = saved })
u := createUser(ctx, "pw", true)
issued, p := login(ctx, svc, u, "pw", nil)
Expect(p.Scopes).To(ContainElement(ScopeAdmin))
u.IsAdmin = false
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
demoted, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).ToNot(HaveOccurred())
Expect(demoted.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))
})
It("rejects the secret after its user is deleted, and the grant row is gone", func() {
u := createUser(ctx, "pw", false)
issued, _ := login(ctx, svc, u, "pw", nil)
Expect(realDS.User().Delete(request.WithUser(ctx, model.User{IsAdmin: true}), u.ID)).To(Succeed())
_, err := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("leaves a login that raced a password change with a dead grant", func() {
u := createUser(ctx, "pw", false)
reached, release := make(chan struct{}), make(chan struct{})
svc.SetCheckers(func(ds model.DataStore) []CredentialChecker {
return []CredentialChecker{pausingChecker{inner: dbChecker{ds: ds}, reached: reached, release: release}}
})
var issued *Issued
var loginErr error
done := make(chan struct{})
go func() {
defer GinkgoRecover()
defer close(done)
issued, loginErr = svc.Login(ctx, u.UserName, "pw", meta, nil)
}()
<-reached // credentials (and the old epoch) were read
u.NewPassword = "changed-meanwhile"
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
close(release)
<-done
Expect(loginErr).ToNot(HaveOccurred())
_, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Describe("grant management", func() {
It("lists the user's grants and marks the current one", func() {
u := createUser(ctx, "pw", false)
first, _ := login(ctx, svc, u, "pw", nil)
_, p := login(ctx, svc, u, "pw", nil)
grants, total, err := svc.ListGrants(ctx, p, 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(total).To(Equal(int64(2)))
Expect(grants).To(HaveLen(2))
Expect([]string{grants[0].ID, grants[1].ID}).To(ContainElements(first.Grant.ID, p.GrantID))
})
It("lists only grants on the user's current epoch", func() {
u := createUser(ctx, "pw", false)
login(ctx, svc, u, "pw", nil)
u.NewPassword = "reset-by-admin" // old-UI reset leaves the old grant on the previous epoch
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
issued, p := login(ctx, svc, u, "reset-by-admin", nil)
grants, total, err := svc.ListGrants(ctx, p, 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(total).To(Equal(int64(1)))
Expect(grants).To(HaveLen(1))
Expect(grants[0].ID).To(Equal(issued.Grant.ID))
})
It("logs out, and succeeds again when the grant is already gone", func() {
u := createUser(ctx, "pw", false)
issued, p := login(ctx, svc, u, "pw", nil)
Expect(svc.Logout(ctx, p)).To(Succeed())
_, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
Expect(svc.Logout(ctx, p)).To(Succeed())
})
It("refuses to revoke another user's grant", func() {
alice := createUser(ctx, "pw", false)
bob := createUser(ctx, "pw", false)
aliceGrant, _ := login(ctx, svc, alice, "pw", nil)
_, bobP := login(ctx, svc, bob, "pw", nil)
Expect(svc.RevokeGrant(ctx, bobP, aliceGrant.Grant.ID)).To(MatchError(model.ErrNotFound))
_, err := svc.Authenticate(ctx, aliceGrant.Secret, "")
Expect(err).ToNot(HaveOccurred())
})
It("rejects the secret after its grant is revoked", func() {
u := createUser(ctx, "pw", false)
other, _ := login(ctx, svc, u, "pw", nil)
_, p := login(ctx, svc, u, "pw", nil)
Expect(svc.RevokeGrant(ctx, p, other.Grant.ID)).To(Succeed())
_, err := svc.Authenticate(ctx, other.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Describe("ChangePassword", func() {
It("revokes other grants by default and keeps the caller's", func() {
u := createUser(ctx, "pw", false)
other, _ := login(ctx, svc, u, "pw", nil)
mine, p := login(ctx, svc, u, "pw", nil)
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)).To(Succeed())
_, err := svc.Authenticate(ctx, mine.Secret, "")
Expect(err).ToNot(HaveOccurred())
_, err = svc.Authenticate(ctx, other.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = svc.Login(ctx, u.UserName, "pw2", meta, nil)
Expect(err).ToNot(HaveOccurred())
})
It("keeps every grant, the caller's included, when revokeOthers is false", func() {
u := createUser(ctx, "pw", false)
other, _ := login(ctx, svc, u, "pw", nil)
mine, p := login(ctx, svc, u, "pw", nil)
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed())
_, err := svc.Authenticate(ctx, other.Secret, "")
Expect(err).ToNot(HaveOccurred())
_, err = svc.Authenticate(ctx, mine.Secret, "")
Expect(err).ToNot(HaveOccurred())
})
It("rejects a wrong current password without changing anything", func() {
u := createUser(ctx, "pw", false)
_, p := login(ctx, svc, u, "pw", nil)
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "wrong", "pw2", true)
Expect(err).To(MatchError(ErrCurrentPasswordMismatch))
_, err = svc.Login(ctx, u.UserName, "pw", meta, nil)
Expect(err).ToNot(HaveOccurred())
})
It("is forbidden for non-admins when user editing is off", func() {
conf.Server.EnableUserEditing = false
u := createUser(ctx, "pw", false)
_, p := login(ctx, svc, u, "pw", nil)
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("does not revive grants killed by an earlier reset when keeping grants", func() {
u := createUser(ctx, "pw", false)
killed, _ := login(ctx, svc, u, "pw", nil)
u.NewPassword = "reset-by-admin" // old-UI reset: the killed grant stays on the old epoch until presented
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
_, p2 := login(ctx, svc, u, "reset-by-admin", nil)
Expect(svc.ChangePassword(request.WithUser(ctx, p2.User), p2, "reset-by-admin", "pw3", false)).To(Succeed())
_, err := svc.Authenticate(ctx, killed.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("rejects a caller whose grant was revoked before the change ran", func() {
u := createUser(ctx, "pw", false)
_, p := login(ctx, svc, u, "pw", nil)
Expect(realDS.Grant().DeleteForUser(ctx, u.ID, p.GrantID)).To(Succeed())
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = svc.Login(ctx, u.UserName, "pw", meta, nil)
Expect(err).ToNot(HaveOccurred())
})
It("rejects a caller naming another user's grant", func() {
alice := createUser(ctx, "pw", false)
bob := createUser(ctx, "pw", false)
_, aliceP := login(ctx, svc, alice, "pw", nil)
bobGrant, _ := login(ctx, svc, bob, "pw", nil)
forged := &Principal{User: aliceP.User, GrantID: bobGrant.Grant.ID}
err := svc.ChangePassword(request.WithUser(ctx, alice), forged, "pw", "pw2", true)
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("rolls back the password and epoch when a grant update fails", func() {
u := createUser(ctx, "pw", false)
issued, p := login(ctx, svc, u, "pw", nil)
failing := New(failingEpochDS{realDS})
failing.SetClock(func() time.Time { return now })
err := failing.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)
Expect(err).To(MatchError(ContainSubstring("boom")))
reloaded, _ := realDS.User().Get(ctx, u.ID)
Expect(reloaded.TokenEpoch).To(Equal(u.TokenEpoch))
_, err = svc.Login(ctx, u.UserName, "pw", meta, nil)
Expect(err).ToNot(HaveOccurred())
_, err = svc.Authenticate(ctx, issued.Secret, "")
Expect(err).ToNot(HaveOccurred())
})
})
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())
})
})
})
// hookDS runs its optional callbacks inside grant lookups, to land a concurrent change mid-Authenticate.
type hookDS struct {
model.DataStore
afterFind func()
beforeDeleteIdle func()
}
func (d hookDS) Grant() model.GrantRepository {
return hookGrants{GrantRepository: d.DataStore.Grant(), hooks: d}
}
type hookGrants struct {
model.GrantRepository
hooks hookDS
}
func (g hookGrants) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) {
found, err := g.GrantRepository.FindBySecretHash(ctx, hash)
if g.hooks.afterFind != nil {
g.hooks.afterFind()
}
return found, err
}
func (g hookGrants) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) {
if g.hooks.beforeDeleteIdle != nil {
g.hooks.beforeDeleteIdle()
}
return g.GrantRepository.DeleteIdle(ctx, idleSince)
}
type pausingChecker struct {
inner CredentialChecker
reached, release chan struct{}
}
func (c pausingChecker) Check(ctx context.Context, username, password string) (CredentialResult, error) {
res, err := c.inner.Check(ctx, username, password)
close(c.reached)
<-c.release
return res, err
}
// failingEpochDS makes SetEpoch fail inside WithTxImmediate, to prove the whole change rolls back.
type failingEpochDS struct{ model.DataStore }
func (f failingEpochDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error {
return f.DataStore.WithTxImmediate(func(tx model.DataStore) error {
return block(failingEpochTx{tx})
}, scope...)
}
type failingEpochTx struct{ model.DataStore }
func (f failingEpochTx) Grant() model.GrantRepository { return failingGrants{f.DataStore.Grant()} }
type failingGrants struct{ model.GrantRepository }
func (failingGrants) SetEpoch(context.Context, string, int, int, string) error {
return errors.New("boom")
}

View file

@ -53,7 +53,7 @@ func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) str
log.Info(ctx, "Creating new JWT secret", "key", key)
return createNewSecret(ctx, ds, key)
}
if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil {
if secret, err = utils.Decrypt(ctx, EncryptionKey(), secret); err != nil {
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err)
return createNewSecret(ctx, ds, key)
}
@ -171,11 +171,12 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
func createNewSecret(ctx context.Context, ds model.DataStore, key string) string {
secret := id.NewRandom()
encSecret, err := utils.Encrypt(ctx, getEncKey(), secret)
encSecret, err := utils.Encrypt(ctx, EncryptionKey(), secret)
if err != nil {
log.Error(ctx, "Could not encrypt JWT secret", err)
return secret
}
ctx = log.WithSecrets(ctx, encSecret)
if err := ds.Property().Put(ctx, key, encSecret); err != nil {
log.Error(ctx, "Could not save JWT secret in DB", err)
}
@ -195,7 +196,7 @@ func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) {
return jwtauth.VerifyToken(TokenAuth, tokenStr)
}
func getEncKey() []byte {
func EncryptionKey() []byte {
key := cmp.Or(
conf.Server.PasswordEncryptionKey,
consts.DefaultEncryptionKey,

View file

@ -15,6 +15,7 @@ import (
)
func TestAuth(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Auth Test Suite")

54
core/auth/first_admin.go Normal file
View file

@ -0,0 +1,54 @@
package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var ErrSetupComplete = errors.New("setup already complete")
// CreateFirstAdmin counts and inserts in one locked transaction, so racing setups cannot both win.
// then, if not nil, runs in that same transaction with the new user.
func CreateFirstAdmin(ctx context.Context, ds model.DataStore, username, password string, then func(tx model.DataStore, u *model.User) error) (*model.User, error) {
var created *model.User
err := ds.WithTxImmediate(func(tx model.DataStore) error {
count, err := tx.User().CountAll(ctx)
if err != nil {
return fmt.Errorf("counting users: %w", err)
}
if count > 0 {
return ErrSetupComplete
}
log.Warn(ctx, "Creating initial user", "user", username)
u := model.User{
ID: id.NewRandom(),
UserName: username,
Name: cases.Title(language.Und).String(username),
NewPassword: password,
IsAdmin: true,
LastLoginAt: new(time.Now()),
}
if err := tx.User().Put(ctx, &u); err != nil {
return fmt.Errorf("creating initial user: %w", err)
}
if created, err = tx.User().Get(ctx, u.ID); err != nil {
return err
}
if then != nil {
return then(tx, created)
}
return nil
})
if err != nil {
return nil, err
}
return created, nil
}

View file

@ -0,0 +1,106 @@
package auth_test
import (
"context"
"errors"
"path/filepath"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("CreateFirstAdmin", Ordered, func() {
var ctx context.Context
var ds model.DataStore
BeforeAll(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "first-admin.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
DeferCleanup(db.Init(GinkgoT().Context()))
ds = persistence.New(db.Db())
})
BeforeEach(func() {
ctx = GinkgoT().Context()
_, err := db.Db().ExecContext(ctx, "delete from user")
Expect(err).ToNot(HaveOccurred())
})
create := func(name string) (*model.User, error) {
return auth.CreateFirstAdmin(ctx, ds, name, "secret", nil)
}
It("creates an admin with a title-cased name and returns it with its id", func() {
u, err := create("john")
Expect(err).ToNot(HaveOccurred())
Expect(u.ID).ToNot(BeEmpty())
Expect(u.IsAdmin).To(BeTrue())
Expect(u.Name).To(Equal("John"))
stored, err := ds.User().FindByUsernameWithPassword(ctx, "john")
Expect(err).ToNot(HaveOccurred())
Expect(stored.Password).To(Equal("secret"))
})
It("refuses once any user exists", func() {
_, err := create("first")
Expect(err).ToNot(HaveOccurred())
_, err = create("second")
Expect(err).To(MatchError(auth.ErrSetupComplete))
})
It("runs then in the same transaction, rolling the user back when it fails", func() {
boom := errors.New("boom")
var seen string
_, err := auth.CreateFirstAdmin(ctx, ds, "john", "secret", func(tx model.DataStore, u *model.User) error {
seen = u.ID
Expect(tx.User().CountAll(ctx)).To(Equal(int64(1)))
return boom
})
Expect(err).To(MatchError(boom))
Expect(seen).ToNot(BeEmpty())
Expect(ds.User().CountAll(ctx)).To(BeZero())
})
It("lets exactly one of two concurrent setups win", func() {
var wg sync.WaitGroup
errs := make([]error, 2)
for i, name := range []string{"racer-a", "racer-b"} {
wg.Add(1)
go func() {
defer GinkgoRecover()
defer wg.Done()
_, errs[i] = auth.CreateFirstAdmin(ctx, slowCountDS{ds}, name, "secret", nil)
}()
}
wg.Wait()
Expect(errs).To(ContainElement(BeNil()))
Expect(errs).To(ContainElement(MatchError(auth.ErrSetupComplete)))
Expect(ds.User().CountAll(ctx)).To(Equal(int64(1)))
})
})
type slowCountDS struct{ model.DataStore }
func (d slowCountDS) User() model.UserRepository { return slowCountUsers{d.DataStore.User()} }
func (d slowCountDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error {
return d.DataStore.WithTxImmediate(func(tx model.DataStore) error { return block(slowCountDS{tx}) }, scope...)
}
type slowCountUsers struct{ model.UserRepository }
// Holds the transaction open after counting, so an unlocked count would interleave with the other racer.
func (u slowCountUsers) CountAll(ctx context.Context, opts ...model.QueryOptions) (int64, error) {
n, err := u.UserRepository.CountAll(ctx, opts...)
time.Sleep(50 * time.Millisecond)
return n, err
}

View file

@ -0,0 +1,23 @@
-- +goose Up
-- +goose StatementBegin
create table api_grant (
id varchar not null primary key,
user_id varchar not null references user(id) on delete cascade,
name varchar not null,
client varchar not null,
client_version varchar not null default '',
scopes varchar not null default '',
provider varchar not null,
secret_hash varchar not null unique,
user_epoch integer not null default 0,
created_at datetime not null,
last_used_at datetime,
last_used_ip varchar not null default ''
);
create index api_grant_user_id on api_grant(user_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
drop table api_grant;
-- +goose StatementEnd

2
go.mod
View file

@ -40,6 +40,7 @@ require (
github.com/mattn/go-sqlite3 v1.14.52
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/oapi-codegen/runtime v1.7.0
github.com/onsi/ginkgo/v2 v2.33.0
github.com/onsi/gomega v1.44.0
github.com/pelletier/go-toml/v2 v2.4.3
@ -74,6 +75,7 @@ require (
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/atombender/go-jsonschema v0.20.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect

11
go.sum
View file

@ -6,14 +6,18 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8=
github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw=
github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ=
@ -136,6 +140,7 @@ github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a8
github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI=
github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
@ -188,6 +193,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k=
github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
@ -255,6 +264,7 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -263,6 +273,7 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

View file

@ -72,7 +72,10 @@ const (
type contextKey string
const loggerCtxKey = contextKey("logger")
const (
loggerCtxKey = contextKey("logger")
secretsCtxKey = contextKey("secrets")
)
type levelPath struct {
path string
@ -188,6 +191,34 @@ func NewContext(ctx context.Context, keyValuePairs ...any) context.Context {
return ctx
}
// Shorter values could match unrelated log text, or the [REDACTED] marker itself.
const minSecretLen = 8
// WithSecrets returns a context whose log entries have every occurrence of values replaced by
// [REDACTED], when redacting is enabled. Values shorter than minSecretLen are ignored.
func WithSecrets(ctx context.Context, values ...string) context.Context {
if ctx == nil {
ctx = context.Background()
}
secrets := slices.Clone(secretsFrom(ctx))
for _, v := range values {
if len(v) >= minSecretLen {
secrets = append(secrets, v)
}
}
// Longest first, so a secret containing another is not left partly visible.
slices.SortStableFunc(secrets, func(a, b string) int { return cmp.Compare(len(b), len(a)) })
return context.WithValue(ctx, secretsCtxKey, secrets)
}
func secretsFrom(ctx context.Context) []string {
if ctx == nil {
return nil
}
secrets, _ := ctx.Value(secretsCtxKey).([]string)
return secrets
}
// SetDefaultLogger swaps the process-wide logger and returns the previous one,
// so tests can restore the original (with its hooks and formatter) on cleanup.
func SetDefaultLogger(l *logrus.Logger) *logrus.Logger {
@ -289,6 +320,12 @@ func parseArgs(args []any) (*logrus.Entry, string) {
if err != nil {
l = createNewLogger()
} else {
switch ctx := args[0].(type) {
case context.Context:
l = l.WithContext(ctx)
case *http.Request:
l = l.WithContext(ctx.Context())
}
args = args[1:]
}
}

View file

@ -1,11 +1,14 @@
package log
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"testing"
"time"
@ -93,9 +96,9 @@ var _ = Describe("Logger", func() {
It("logs source file and line number, if requested", func() {
SetLogSourceLine(true)
_, _, line, _ := runtime.Caller(0)
Error("A crash happened")
// NOTE: This assertion breaks if the line number above changes
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:96"))
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring(fmt.Sprintf("/log/log_test.go:%d", line+1)))
Expect(hook.LastEntry().Message).To(Equal("A crash happened"))
})
@ -109,6 +112,26 @@ var _ = Describe("Logger", func() {
Error("Simple Message", "key1", t)
Expect(hook.LastEntry().Data["key1"]).To(Equal("nil"))
})
It("passes the call's context to hooks", func() {
ctx := WithSecrets(GinkgoT().Context(), "s3cr3t-value")
Error(ctx, "Simple Message")
Expect(hook.LastEntry().Context).To(Equal(ctx))
Error(httptest.NewRequest("get", "/", nil).WithContext(ctx), "Simple Message")
Expect(hook.LastEntry().Context).To(Equal(ctx))
})
It("redacts the context's secrets when redacting is on", func() {
l.AddHook(redacted)
ctx := WithSecrets(NewContext(GinkgoT().Context(), "user", "admin"), "s3cr3t-value")
var buf bytes.Buffer
l.SetOutput(&buf)
Error(ctx, "Saving s3cr3t-value", "args", map[string]any{"value": "s3cr3t-value"})
Expect(buf.String()).ToNot(ContainSubstring("s3cr3t-value"))
Expect(buf.String()).To(ContainSubstring("user=admin"))
})
})
Describe("Levels", func() {

View file

@ -7,6 +7,7 @@ import (
"fmt"
"reflect"
"regexp"
"strings"
"github.com/sirupsen/logrus"
)
@ -35,6 +36,7 @@ func (h *Hook) Fire(e *logrus.Entry) error {
if err := h.initRedaction(); err != nil {
return err
}
redactSecrets(e)
for _, re := range h.redactionKeys {
// Redact based on key matching in Data fields
for k, v := range e.Data {
@ -47,7 +49,8 @@ func (h *Hook) Fire(e *logrus.Entry) error {
}
switch reflect.TypeOf(v).Kind() {
case reflect.String:
e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2")
// Via reflect: named string types (e.g. enums) have Kind String but fail v.(string).
e.Data[k] = re.ReplaceAllString(reflect.ValueOf(v).String(), "$1[REDACTED]$2")
continue
case reflect.Map:
s := fmt.Sprintf("%+v", v)
@ -63,6 +66,36 @@ func (h *Hook) Fire(e *logrus.Entry) error {
return nil
}
// redactSecrets hides the values marked with WithSecrets in the context the entry was logged with.
func redactSecrets(e *logrus.Entry) {
secrets := secretsFrom(e.Context)
if len(secrets) == 0 {
return
}
hide := func(s string) string {
for _, secret := range secrets {
s = strings.ReplaceAll(s, secret, "[REDACTED]")
}
return s
}
e.Message = hide(e.Message)
for k, v := range e.Data {
if v == nil {
continue
}
// fmt.Sprint renders like the text formatter and survives typed-nil errors; []byte is written raw.
var s string
if b, ok := v.([]byte); ok {
s = string(b)
} else {
s = fmt.Sprint(v)
}
if hidden := hide(s); hidden != s {
e.Data[k] = hidden
}
}
}
func (h *Hook) initRedaction() error {
if len(h.redactionKeys) == 0 {
for _, redactionKey := range h.RedactionList {

View file

@ -1,6 +1,8 @@
package log
import (
"errors"
"net/url"
"testing"
"github.com/sirupsen/logrus"
@ -157,3 +159,85 @@ func TestEntryMessage(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, "Secret Password: [REDACTED]", logEntry.Message)
}
type namedString string
func TestFireRedactsNamedStringTypes(t *testing.T) {
hook := &Hook{RedactionList: []string{"(secret=)[^&]+"}}
e := &logrus.Entry{Data: logrus.Fields{"code": namedString("not_found"), "url": namedString("/x?secret=abc")}}
assert.NotPanics(t, func() { _ = hook.Fire(e) })
assert.Equal(t, "not_found", e.Data["code"])
assert.Equal(t, "/x?secret=[REDACTED]", e.Data["url"])
}
func TestFireRedactsContextSecrets(t *testing.T) {
ctx := WithSecrets(t.Context(), "s3cr3t-value")
ctx = WithSecrets(ctx, "", "other-secret")
e := &logrus.Entry{
Context: ctx,
Message: "value s3cr3t-value in message",
Data: logrus.Fields{
"str": "has s3cr3t-value",
"named": namedString("named other-secret"),
"args": map[string]any{"p0": "s3cr3t-value", "p1": "plain"},
"error": errors.New("failed with other-secret"),
"num": 42,
"clean": namedString("untouched"),
},
}
assert.Nil(t, (&Hook{}).Fire(e))
assert.Equal(t, "value [REDACTED] in message", e.Message)
assert.Equal(t, "has [REDACTED]", e.Data["str"])
assert.Equal(t, "named [REDACTED]", e.Data["named"])
assert.Equal(t, "map[p0:[REDACTED] p1:plain]", e.Data["args"])
assert.Equal(t, "failed with [REDACTED]", e.Data["error"])
assert.Equal(t, 42, e.Data["num"])
assert.Equal(t, namedString("untouched"), e.Data["clean"])
}
func TestFireRedactsContextSecretsInAnyValueType(t *testing.T) {
ctx := WithSecrets(t.Context(), "s3cr3t-value")
var nilErr *url.Error
e := &logrus.Entry{
Context: ctx,
Data: logrus.Fields{
"slice": []any{"s3cr3t-value", 1},
"struct": struct{ A string }{"s3cr3t-value"},
"bytes": []byte("has s3cr3t-value"),
"nilErr": nilErr,
},
}
assert.NotPanics(t, func() { _ = (&Hook{}).Fire(e) })
assert.Equal(t, "[[REDACTED] 1]", e.Data["slice"])
assert.Equal(t, "{[REDACTED]}", e.Data["struct"])
assert.Equal(t, "has [REDACTED]", e.Data["bytes"])
assert.Equal(t, nilErr, e.Data["nilErr"])
}
func TestFireWithoutContextSecretsLeavesEntryUnchanged(t *testing.T) {
args := map[string]any{"p0": "value"}
e := &logrus.Entry{Context: t.Context(), Message: "value", Data: logrus.Fields{"str": "value", "args": args}}
assert.Nil(t, (&Hook{}).Fire(e))
assert.Equal(t, "value", e.Message)
assert.Equal(t, logrus.Fields{"str": "value", "args": args}, e.Data)
}
func TestFireRedactsLongerSecretsFirst(t *testing.T) {
ctx := WithSecrets(t.Context(), "abcdefgh", "abcdefghijkl")
e := &logrus.Entry{Context: ctx, Message: "abcdefghijkl"}
assert.Nil(t, (&Hook{}).Fire(e))
assert.Equal(t, "[REDACTED]", e.Message)
}
func TestFireIgnoresShortSecrets(t *testing.T) {
ctx := WithSecrets(t.Context(), "abc")
e := &logrus.Entry{Context: ctx, Message: "abc in UPDATE ... abc"}
assert.Nil(t, (&Hook{}).Fire(e))
assert.Equal(t, "abc in UPDATE ... abc", e.Message)
}

View file

@ -37,6 +37,7 @@ type DataStore interface {
Plugin() PluginRepository
Artwork() ArtworkRepository
ArtworkQueue() ArtworkQueueRepository
Grant() GrantRepository
WithTx(block func(tx DataStore) error, scope ...string) error
WithTxImmediate(block func(tx DataStore) error, scope ...string) error

67
model/grant.go Normal file
View file

@ -0,0 +1,67 @@
package model
import (
"context"
"database/sql/driver"
"fmt"
"strings"
"time"
)
type Grant struct {
ID string `structs:"id" json:"id"`
UserID string `structs:"user_id" json:"userId"`
Name string `structs:"name" json:"name"`
Client string `structs:"client" json:"client"`
ClientVersion string `structs:"client_version" json:"clientVersion"`
Scopes Scopes `structs:"scopes" json:"scopes"`
Provider string `structs:"provider" json:"provider"`
SecretHash string `structs:"secret_hash" json:"-"`
UserEpoch int `structs:"user_epoch" json:"-"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
LastUsedAt *time.Time `structs:"last_used_at" json:"lastUsedAt"`
LastUsedIP string `structs:"last_used_ip" json:"lastUsedIp"`
}
func (g Grant) LastActivity() time.Time {
if g.LastUsedAt != nil {
return *g.LastUsedAt
}
return g.CreatedAt
}
type Grants []Grant
// Scopes is stored as a single space-separated column.
type Scopes []string
func (s Scopes) Value() (driver.Value, error) {
return strings.Join(s, " "), nil
}
func (s *Scopes) Scan(src any) error {
switch v := src.(type) {
case string:
*s = strings.Fields(v)
case []byte:
*s = strings.Fields(string(v))
case nil:
*s = nil
default:
return fmt.Errorf("cannot scan %T into Scopes", src)
}
return nil
}
type GrantRepository interface {
Put(ctx context.Context, g *Grant) error
Get(ctx context.Context, id string) (*Grant, error)
FindBySecretHash(ctx context.Context, hash string) (*Grant, error)
GetAllForUser(ctx context.Context, userID string, epoch int, idleSince time.Time, offset, limit int) (Grants, error)
CountForUser(ctx context.Context, userID string, epoch int, idleSince time.Time) (int64, error)
DeleteForUser(ctx context.Context, userID, id string) error
DeleteStaleEpochs(ctx context.Context, userID string, currentEpoch int) error
SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error
Touch(ctx context.Context, id, ip string, at, notSince time.Time) error
DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error)
}

24
model/grant_test.go Normal file
View file

@ -0,0 +1,24 @@
package model_test
import (
"time"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Grant", func() {
Describe("LastActivity", func() {
created := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
It("is the creation time for a grant never used", func() {
Expect(model.Grant{CreatedAt: created}.LastActivity()).To(Equal(created))
})
It("is the last use once the grant was used", func() {
used := created.Add(time.Hour)
Expect(model.Grant{CreatedAt: created, LastUsedAt: &used}.LastActivity()).To(Equal(used))
})
})
})

View file

@ -0,0 +1,114 @@
package persistence
import (
"context"
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/pocketbase/dbx"
)
type grantRepository struct {
sqlRepository
}
func NewGrantRepository(db dbx.Builder) model.GrantRepository {
r := &grantRepository{}
r.db = db
r.tableName = "api_grant"
return r
}
const grantLastActivity = "COALESCE(last_used_at, created_at)"
func (r *grantRepository) Put(ctx context.Context, g *model.Grant) error {
if g.ID == "" {
g.ID = id.NewRandom()
}
if g.CreatedAt.IsZero() {
g.CreatedAt = time.Now()
}
// Stored as UTC: SQLite compares these timestamps as strings.
g.CreatedAt = g.CreatedAt.UTC()
if g.LastUsedAt != nil {
t := g.LastUsedAt.UTC()
g.LastUsedAt = &t
}
values, err := toSQLArgs(*g)
if err != nil {
return err
}
_, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values))
return err
}
func (r *grantRepository) Get(ctx context.Context, id string) (*model.Grant, error) {
return r.findOne(ctx, Eq{"id": id})
}
func (r *grantRepository) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) {
return r.findOne(ctx, Eq{"secret_hash": hash})
}
func (r *grantRepository) findOne(ctx context.Context, cond Sqlizer) (*model.Grant, error) {
var g model.Grant
if err := r.queryOne(ctx, r.newSelect(ctx).Columns("*").Where(cond), &g); err != nil {
return nil, err
}
return &g, nil
}
func activeForUser(userID string, epoch int, idleSince time.Time) Sqlizer {
return And{Eq{"user_id": userID, "user_epoch": epoch}, Expr(grantLastActivity+" >= ?", idleSince.UTC())}
}
func (r *grantRepository) GetAllForUser(ctx context.Context, userID string, epoch int, idleSince time.Time, offset, limit int) (model.Grants, error) {
sel := r.newSelect(ctx).Columns("*").Where(activeForUser(userID, epoch, idleSince)).
OrderBy("last_used_at IS NULL", "last_used_at desc", "created_at desc", "id").
Offset(uint64(offset)).Limit(uint64(limit))
var res model.Grants
err := r.queryAll(ctx, sel, &res)
return res, err
}
func (r *grantRepository) CountForUser(ctx context.Context, userID string, epoch int, idleSince time.Time) (int64, error) {
return r.count(ctx, Select().Where(activeForUser(userID, epoch, idleSince)))
}
func (r *grantRepository) DeleteForUser(ctx context.Context, userID, id string) error {
n, err := r.executeSQL(ctx, Delete(r.tableName).Where(Eq{"id": id, "user_id": userID}))
if err != nil {
return err
}
if n == 0 {
return model.ErrNotFound
}
return nil
}
func (r *grantRepository) DeleteStaleEpochs(ctx context.Context, userID string, currentEpoch int) error {
return r.delete(ctx, And{Eq{"user_id": userID}, Lt{"user_epoch": currentEpoch}})
}
// SetEpoch only moves grants still on fromEpoch, so grants killed by an earlier change never come back.
func (r *grantRepository) SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error {
cond := Eq{"user_id": userID, "user_epoch": fromEpoch}
if onlyID != "" {
cond["id"] = onlyID
}
_, err := r.executeSQL(ctx, Update(r.tableName).Set("user_epoch", toEpoch).Where(cond))
return err
}
func (r *grantRepository) Touch(ctx context.Context, id, ip string, at, notSince time.Time) error {
upd := Update(r.tableName).Set("last_used_at", at.UTC()).Set("last_used_ip", ip).
Where(And{Eq{"id": id}, Or{Eq{"last_used_at": nil}, Lt{"last_used_at": notSince.UTC()}}})
_, err := r.executeSQL(ctx, upd)
return err
}
func (r *grantRepository) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) {
return r.executeSQL(ctx, Delete(r.tableName).Where(Expr(grantLastActivity+" < ?", idleSince.UTC())))
}

View file

@ -0,0 +1,206 @@
package persistence
import (
"context"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("GrantRepository", func() {
var ctx context.Context
var repo model.GrantRepository
var now time.Time
newGrant := func(userID, hash string) *model.Grant {
return &model.Grant{UserID: userID, Name: "TV", Client: "TestApp", Scopes: model.Scopes{"all"},
Provider: "password", SecretHash: hash, CreatedAt: now}
}
BeforeEach(func() {
ctx = log.NewContext(GinkgoT().Context())
repo = NewGrantRepository(GetDBXBuilder())
now = time.Now().UTC().Truncate(time.Second)
DeferCleanup(func() {
_, _ = GetDBXBuilder().NewQuery("delete from api_grant").Execute()
})
})
It("stores a grant and finds it by id and by secret hash", func() {
g := newGrant(adminUser.ID, "hash-1")
g.Scopes = model.Scopes{"read", "password"}
Expect(repo.Put(ctx, g)).To(Succeed())
Expect(g.ID).ToNot(BeEmpty())
byID, err := repo.Get(ctx, g.ID)
Expect(err).ToNot(HaveOccurred())
Expect(byID.Scopes).To(Equal(model.Scopes{"read", "password"}))
Expect(byID.LastUsedAt).To(BeNil())
Expect(byID.LastUsedIP).To(BeEmpty())
byHash, err := repo.FindBySecretHash(ctx, "hash-1")
Expect(err).ToNot(HaveOccurred())
Expect(byHash.ID).To(Equal(g.ID))
})
It("returns ErrNotFound for unknown ids and hashes", func() {
_, err := repo.Get(ctx, "nope")
Expect(err).To(MatchError(model.ErrNotFound))
_, err = repo.FindBySecretHash(ctx, "nope")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("lists and counts only the user's non-idle grants on the given epoch by lastUsedAt, never-used ones last", func() {
old := newGrant(adminUser.ID, "h-old")
old.CreatedAt = now.Add(-100 * 24 * time.Hour)
usedEarly := newGrant(adminUser.ID, "h-used-early")
usedEarly.CreatedAt = now.Add(-10 * time.Hour)
earlyUse := now.Add(-5 * time.Hour)
usedEarly.LastUsedAt = &earlyUse
usedLate := newGrant(adminUser.ID, "h-used-late")
usedLate.CreatedAt = now.Add(-10 * time.Hour)
lateUse := now.Add(-time.Hour)
usedLate.LastUsedAt = &lateUse
freshNeverUsed := newGrant(adminUser.ID, "h-fresh") // newer than both uses, but never used
other := newGrant(regularUser.ID, "h-other")
staleEpoch := newGrant(adminUser.ID, "h-stale-epoch")
staleEpoch.UserEpoch = 1
for _, g := range []*model.Grant{old, usedEarly, usedLate, freshNeverUsed, other, staleEpoch} {
Expect(repo.Put(ctx, g)).To(Succeed())
}
idleSince := now.Add(-90 * 24 * time.Hour)
list, err := repo.GetAllForUser(ctx, adminUser.ID, 0, idleSince, 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(3))
Expect([]string{list[0].ID, list[1].ID, list[2].ID}).To(Equal([]string{usedLate.ID, usedEarly.ID, freshNeverUsed.ID}))
Expect(repo.CountForUser(ctx, adminUser.ID, 0, idleSince)).To(Equal(int64(3)))
page, err := repo.GetAllForUser(ctx, adminUser.ID, 0, idleSince, 1, 1)
Expect(err).ToNot(HaveOccurred())
Expect(page).To(HaveLen(1))
Expect(page[0].ID).To(Equal(usedEarly.ID))
})
It("deletes a grant only for its owner", func() {
g := newGrant(adminUser.ID, "h-own")
Expect(repo.Put(ctx, g)).To(Succeed())
Expect(repo.DeleteForUser(ctx, regularUser.ID, g.ID)).To(MatchError(model.ErrNotFound))
Expect(repo.DeleteForUser(ctx, adminUser.ID, g.ID)).To(Succeed())
_, err := repo.Get(ctx, g.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("moves epochs forward", func() {
keep := newGrant(adminUser.ID, "h-keep")
stay := newGrant(adminUser.ID, "h-stay")
Expect(repo.Put(ctx, keep)).To(Succeed())
Expect(repo.Put(ctx, stay)).To(Succeed())
Expect(repo.SetEpoch(ctx, adminUser.ID, 0, 3, keep.ID)).To(Succeed())
kept, err := repo.Get(ctx, keep.ID)
Expect(err).ToNot(HaveOccurred())
Expect(kept.UserEpoch).To(Equal(3))
stayed, _ := repo.Get(ctx, stay.ID)
Expect(stayed.UserEpoch).To(Equal(0))
Expect(repo.SetEpoch(ctx, adminUser.ID, 3, 4, "")).To(Succeed())
kept, _ = repo.Get(ctx, keep.ID)
Expect(kept.UserEpoch).To(Equal(4))
})
It("never moves a grant that is not on fromEpoch", func() {
stale := newGrant(adminUser.ID, "h-stale") // left behind by an earlier password change
stale.UserEpoch = 1
current := newGrant(adminUser.ID, "h-current")
current.UserEpoch = 2
Expect(repo.Put(ctx, stale)).To(Succeed())
Expect(repo.Put(ctx, current)).To(Succeed())
Expect(repo.SetEpoch(ctx, adminUser.ID, 2, 3, "")).To(Succeed())
got, _ := repo.Get(ctx, stale.ID)
Expect(got.UserEpoch).To(Equal(1))
got, _ = repo.Get(ctx, current.ID)
Expect(got.UserEpoch).To(Equal(3))
})
It("deletes only the user's grants on an epoch before the current one", func() {
older := newGrant(adminUser.ID, "h-older")
older.UserEpoch = 1
previous := newGrant(adminUser.ID, "h-previous")
previous.UserEpoch = 4
current := newGrant(adminUser.ID, "h-current")
current.UserEpoch = 5
otherUser := newGrant(regularUser.ID, "h-other-user")
otherUser.UserEpoch = 1
for _, g := range []*model.Grant{older, previous, current, otherUser} {
Expect(repo.Put(ctx, g)).To(Succeed())
}
Expect(repo.DeleteStaleEpochs(ctx, adminUser.ID, 5)).To(Succeed())
for _, g := range []*model.Grant{older, previous} {
_, err := repo.Get(ctx, g.ID)
Expect(err).To(MatchError(model.ErrNotFound))
}
for _, g := range []*model.Grant{current, otherUser} {
_, err := repo.Get(ctx, g.ID)
Expect(err).ToNot(HaveOccurred())
}
})
It("touches a never-used grant, then throttles until notSince passes", func() {
g := newGrant(adminUser.ID, "h-touch")
Expect(repo.Put(ctx, g)).To(Succeed())
Expect(repo.Touch(ctx, g.ID, "10.0.0.1", now, now.Add(-5*time.Minute))).To(Succeed())
got, _ := repo.Get(ctx, g.ID)
Expect(got.LastUsedAt).ToNot(BeNil())
Expect(got.LastUsedAt.UTC()).To(BeTemporally("==", now))
Expect(got.LastUsedIP).To(Equal("10.0.0.1"))
later := now.Add(time.Minute)
Expect(repo.Touch(ctx, g.ID, "10.0.0.2", later, later.Add(-5*time.Minute))).To(Succeed())
got, _ = repo.Get(ctx, g.ID)
Expect(got.LastUsedIP).To(Equal("10.0.0.1"))
muchLater := now.Add(6 * time.Minute)
Expect(repo.Touch(ctx, g.ID, "10.0.0.3", muchLater, muchLater.Add(-5*time.Minute))).To(Succeed())
got, _ = repo.Get(ctx, g.ID)
Expect(got.LastUsedIP).To(Equal("10.0.0.3"))
})
It("deletes idle grants, using created_at for never-used ones", func() {
idle := newGrant(adminUser.ID, "h-idle")
idle.CreatedAt = now.Add(-100 * 24 * time.Hour)
usedRecently := newGrant(adminUser.ID, "h-used-recently")
usedRecently.CreatedAt = now.Add(-100 * 24 * time.Hour)
recentUse := now.Add(-time.Hour)
usedRecently.LastUsedAt = &recentUse
Expect(repo.Put(ctx, idle)).To(Succeed())
Expect(repo.Put(ctx, usedRecently)).To(Succeed())
n, err := repo.DeleteIdle(ctx, now.Add(-90*24*time.Hour))
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(int64(1)))
_, err = repo.Get(ctx, usedRecently.ID)
Expect(err).ToNot(HaveOccurred())
})
It("deletes a user's grants when the user is deleted", func() {
users := NewUserRepository(GetDBXBuilder())
u := model.User{ID: "grant-owner", UserName: "grant-owner", NewPassword: "pw"}
Expect(users.Put(ctx, &u)).To(Succeed())
g := newGrant(u.ID, "h-cascade")
Expect(repo.Put(ctx, g)).To(Succeed())
Expect(users.Delete(request.WithUser(ctx, adminUser), u.ID)).To(Succeed())
_, err := repo.Get(ctx, g.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
})

View file

@ -7,6 +7,7 @@ import (
"sync"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -37,6 +38,7 @@ type SQLStore struct {
plugin func() model.PluginRepository
artwork func() model.ArtworkRepository
artworkQueue func() model.ArtworkQueueRepository
grant func() model.GrantRepository
}
// Repositories are built on first use, so a transaction store only pays for the ones its block touches.
@ -64,6 +66,7 @@ func newSQLStore(db dbx.Builder) *SQLStore {
plugin: sync.OnceValue(func() model.PluginRepository { return NewPluginRepository(db) }),
artwork: sync.OnceValue(func() model.ArtworkRepository { return NewArtworkRepository(db) }),
artworkQueue: sync.OnceValue(func() model.ArtworkQueueRepository { return NewArtworkQueueRepository(db) }),
grant: sync.OnceValue(func() model.GrantRepository { return NewGrantRepository(db) }),
}
}
@ -155,6 +158,10 @@ func (s *SQLStore) ArtworkQueue() model.ArtworkQueueRepository {
return s.artworkQueue()
}
func (s *SQLStore) Grant() model.GrantRepository {
return s.grant()
}
func scopeLabel(scope []string) string {
if len(scope) > 0 {
return scope[0]
@ -271,6 +278,10 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error {
trace(ctx, "clean media file bookmarks", func() error { return s.mediaFile().(*mediaFileRepository).cleanBookmarks(ctx) }),
trace(ctx, "purge non used tags", func() error { return s.tag().(*tagRepository).purgeUnused(ctx) }),
trace(ctx, "remove orphan playlist tracks", func() error { return s.playlist().(*playlistRepository).removeOrphans(ctx) }),
trace(ctx, "purge idle API grants", func() error {
_, err := s.grant().DeleteIdle(ctx, time.Now().Add(-consts.APIv1GrantIdleExpiry))
return err
}),
)
if err != nil {
return fmt.Errorf("tidying up database: %w", err)

View file

@ -1,7 +1,9 @@
package persistence
import (
"bytes"
"context"
"os"
"path/filepath"
"testing"
"time"
@ -348,6 +350,18 @@ var _ = BeforeSuite(func() {
}
})
// captureTraceLogs sends trace logs, SQL included, to a buffer for the rest of the spec.
func captureTraceLogs() *bytes.Buffer {
buf := &bytes.Buffer{}
log.SetOutput(buf)
log.SetLevel(log.LevelTrace)
DeferCleanup(func() {
log.SetOutput(os.Stderr)
log.SetLevel(log.LevelFatal)
})
return buf
}
func GetDBXBuilder() *dbx.DB {
return dbx.NewFromDB(db.Db(), db.Dialect)
}

View file

@ -33,4 +33,26 @@ var _ = Describe("Property Repository", func() {
It("returns a default value if property does not exist", func() {
Expect(pr.DefaultGet(ctx, "2", "default")).To(Equal("default"))
})
It("hides values marked as secrets from the SQL log, but still logs the property id", func() {
logs := captureTraceLogs()
insertCtx := log.WithSecrets(ctx, "inserted-secret")
Expect(pr.Put(insertCtx, "secret-prop", "inserted-secret")).To(Succeed())
updateCtx := log.WithSecrets(ctx, "updated-secret")
Expect(pr.Put(updateCtx, "secret-prop", "updated-secret")).To(Succeed())
Expect(logs.String()).To(ContainSubstring("INSERT INTO property"))
Expect(logs.String()).To(ContainSubstring("UPDATE property"))
Expect(logs.String()).To(ContainSubstring("secret-prop"))
Expect(logs.String()).ToNot(ContainSubstring("inserted-secret"))
Expect(logs.String()).ToNot(ContainSubstring("updated-secret"))
})
It("logs the values of unmarked property writes", func() {
logs := captureTraceLogs()
Expect(pr.Put(ctx, "plain-prop", "plain-value")).To(Succeed())
Expect(logs.String()).To(ContainSubstring("plain-prop"))
Expect(logs.String()).To(ContainSubstring("plain-value"))
})
})

View file

@ -119,6 +119,7 @@ func (r *userRepository) Put(ctx context.Context, u *model.User) error {
u.UpdatedAt = time.Now()
if u.NewPassword != "" {
_ = r.encryptPassword(ctx, u)
ctx = log.WithSecrets(ctx, u.NewPassword)
}
values, err := toSQLArgs(*u)
if err != nil {
@ -399,6 +400,7 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error {
key := keyTo32Bytes(conf.Server.PasswordEncryptionKey)
keySum := fmt.Sprintf("%x", sha256.Sum256(key))
ctx = log.WithSecrets(ctx, keySum)
props := NewPropertyRepository(r.db)
savedKeySum, err := props.Get(ctx, consts.PasswordsEncryptedKey)
@ -432,7 +434,8 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error {
u.NewPassword = u.Password
if err := r.encryptPassword(ctx, &u); err == nil {
upd := Update(r.tableName).Set("password", u.NewPassword).Where(Eq{"id": u.ID})
_, err = r.executeSQL(ctx, upd)
userCtx := log.WithSecrets(ctx, u.NewPassword)
_, err = r.executeSQL(userCtx, upd)
if err != nil {
log.Error("Password NOT encrypted! This may cause problems!", "user", u.UserName, "id", u.ID, err)
} else {

View file

@ -2,12 +2,16 @@ package persistence
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"slices"
"sync"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -17,6 +21,7 @@ import (
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
var _ = Describe("UserRepository", func() {
@ -74,6 +79,28 @@ var _ = Describe("UserRepository", func() {
Expect(err).ToNot(HaveOccurred())
Expect(actual.Password).To(Equal("newpass"))
})
It("never logs the stored password, on insert or update, but still logs the user name", func() {
logs := captureTraceLogs()
storedPassword := func(id string) string {
var enc string
Expect(GetDBXBuilder().NewQuery("select password from user where id = {:id}").
Bind(dbx.Params{"id": id}).Row(&enc)).To(Succeed())
return enc
}
u := model.User{ID: "u-logged", UserName: "logged-user-name", NewPassword: "first-secret"}
Expect(repo.Put(ctx, &u)).To(Succeed())
inserted := storedPassword(u.ID)
u.NewPassword = "second-secret"
Expect(repo.Put(ctx, &u)).To(Succeed())
updated := storedPassword(u.ID)
Expect(logs.String()).To(ContainSubstring("INSERT INTO user"))
Expect(logs.String()).To(ContainSubstring("UPDATE user"))
Expect(logs.String()).To(ContainSubstring("logged-user-name"))
for _, secret := range []string{inserted, updated, "first-secret", "second-secret"} {
Expect(logs.String()).ToNot(ContainSubstring(secret))
}
})
It("persists and reads back the scrobble filter", func() {
usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User",
ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`}
@ -96,6 +123,33 @@ var _ = Describe("UserRepository", func() {
})
})
Describe("initPasswordEncryptionKey", func() {
It("never logs the encryption key checksum, but still logs its property id", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PasswordEncryptionKey = "a-new-password-encryption-key"
keySum := fmt.Sprintf("%x", sha256.Sum256(keyTo32Bytes(conf.Server.PasswordEncryptionKey)))
previousKey := encKey
DeferCleanup(func() { encKey = previousKey })
tx, err := GetDBXBuilder().Begin()
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { _ = tx.Rollback() })
_, err = tx.NewQuery("delete from user").Execute()
Expect(err).ToNot(HaveOccurred())
txRepo := NewUserRepository(tx).(*userRepository)
Expect(txRepo.Put(ctx, &model.User{ID: "u-rekey", UserName: "rekeyed-user", NewPassword: "rekeyed-password"})).To(Succeed())
logs := captureTraceLogs()
Expect(txRepo.initPasswordEncryptionKey(ctx)).To(Succeed())
Expect(logs.String()).To(ContainSubstring("UPDATE user"))
Expect(logs.String()).To(ContainSubstring(consts.PasswordsEncryptedKey))
Expect(logs.String()).ToNot(ContainSubstring(keySum))
var rekeyed string
Expect(tx.NewQuery("select password from user where id = 'u-rekey'").Row(&rekeyed)).To(Succeed())
Expect(logs.String()).ToNot(ContainSubstring(rekeyed))
})
})
Describe("validatePasswordChange", func() {
var loggedUser *model.User

View file

@ -1,32 +1,53 @@
package apiv1
import (
"cmp"
"errors"
"net/http"
"runtime/debug"
"slices"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/api"
"github.com/navidrome/navidrome/core/apiauth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
const maxBodyBytes = 1 << 20
type Router struct {
http.Handler
ds model.DataStore
ds model.DataStore
auth *apiauth.Service
}
func New(ds model.DataStore) *Router {
rt := &Router{ds: ds}
rt := &Router{ds: ds, auth: apiauth.New(ds)}
rt.Handler = rt.routes()
return rt
}
var gateRulesV1 = gateRules{
limited: map[string]bool{"login": true, "setupFirstAdmin": true, "changePassword": true},
noScope: map[string]bool{"getCapabilities": true},
noStore: map[string]bool{"login": true, "setupFirstAdmin": true},
}
func (rt *Router) routes() http.Handler {
r := chi.NewRouter()
r.Use(problemRecoverer, headAsGet(r))
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
if err != nil {
log.Fatal("API v1: cannot load the embedded OpenAPI spec", err)
}
g, err := newGate(doc, r, rt.auth, gateRulesV1)
if err != nil {
log.Fatal("API v1: the embedded OpenAPI spec breaks the security rules", err)
}
r.Use(referenceIDMiddleware, problemRecoverer, headAsGet(r), middleware.RequestSize(maxBodyBytes), g.handler)
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
writeProblemStatus(w, req, http.StatusNotFound, ProblemCodeNotFound, "no such endpoint")
})
@ -40,11 +61,18 @@ func (rt *Router) routes() http.Handler {
strict := NewStrictHandlerWithOptions(rt, nil, StrictHTTPServerOptions{
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
writeProblemStatus(w, req, http.StatusBadRequest, "validation", err.Error())
if tooLarge(err) {
writeProblem(w, req, ClientError(err, tooLargeDetail))
return
}
writeProblemStatus(w, req, http.StatusBadRequest, ProblemCodeValidation, "request body is not valid JSON")
},
ResponseErrorHandlerFunc: writeProblem,
})
HandlerWithOptions(strict, ChiServerOptions{BaseRouter: r, ErrorHandlerFunc: bindingErrorHandler})
if err := g.checkRoutes(); err != nil {
log.Fatal("API v1: routes and the embedded OpenAPI spec disagree", err)
}
return r
}
@ -90,9 +118,18 @@ func headAsGet(mux chi.Routes) func(http.Handler) http.Handler {
}
}
// routeMethod is the method chi dispatches on, which headAsGet sets to GET for a HEAD only GET serves.
func routeMethod(req *http.Request) string {
if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RouteMethod != "" {
return rctx.RouteMethod
}
return req.Method
}
// routePath must pick the same path chi's routeHTTP dispatches on, or the gate could vet a different route.
func routePath(req *http.Request) string {
if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" {
return rctx.RoutePath
}
return req.URL.Path
return cmp.Or(req.URL.RawPath, req.URL.Path, "/")
}

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,11 @@ package apiv1
import (
"net/http"
"net/http/httptest"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/api"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -16,6 +20,40 @@ var _ = Describe("Router", func() {
router = New(&tests.MockDataStore{})
})
It("routes every operation in the embedded spec", func() {
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
Expect(err).ToNot(HaveOccurred())
mux := New(&tests.MockDataStore{}).Handler.(chi.Routes)
for path, item := range doc.Paths.Map() {
for method := range item.Operations() {
Expect(mux.Find(chi.NewRouteContext(), method, path)).To(Equal(path), method+" "+path)
}
}
})
It("declares Cache-Control no-store on the success responses of every no-store operation", func() {
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
Expect(err).ToNot(HaveOccurred())
checked := map[string]bool{}
for _, item := range doc.Paths.Map() {
for _, op := range item.Operations() {
if !gateRulesV1.noStore[op.OperationID] {
continue
}
for code, resp := range op.Responses.Map() {
if !strings.HasPrefix(code, "2") {
continue
}
h := resp.Value.Headers["Cache-Control"]
Expect(h).ToNot(BeNil(), op.OperationID+" "+code)
Expect(h.Value.Schema.Value.Enum).To(ConsistOf("no-store"), op.OperationID+" "+code)
checked[op.OperationID] = true
}
}
}
Expect(checked).To(HaveLen(len(gateRulesV1.noStore)))
})
It("returns a 404 problem for unknown paths", func() {
w := serve(router, httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil))
Expect(w.Code).To(Equal(http.StatusNotFound))
@ -67,6 +105,19 @@ var _ = Describe("Router", func() {
Expect(p.Detail).To(BeNil())
})
It("tags internal errors with a referenceId that is also on the request's log lines", func() {
logs := captureLogs()
w := httptest.NewRecorder()
h := referenceIDMiddleware(problemRecoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("kaboom")
})))
h.ServeHTTP(w, httptest.NewRequestWithContext(GinkgoT().Context(), http.MethodGet, "/boom", nil))
p := decodeProblem(w)
Expect(p.ReferenceId).ToNot(BeNil())
Expect(*p.ReferenceId).To(MatchRegexp(`^[0-9A-Za-z]{22}$`))
Expect(logs.String()).To(ContainSubstring(*p.ReferenceId))
})
It("re-panics http.ErrAbortHandler so the server can drop the connection", func() {
Expect(func() {
panicking(http.ErrAbortHandler).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/abort", nil))

View file

@ -2,10 +2,14 @@ package apiv1
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/getkin/kin-openapi/openapi3"
@ -14,7 +18,11 @@ import (
"github.com/getkin/kin-openapi/routers/gorillamux"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/api"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -29,7 +37,13 @@ func TestAPIv1(t *testing.T) {
var specRouter routers.Router
// One database for the suite (db.Db() is a process-wide singleton); each spec clears users and grants.
var _ = BeforeSuite(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "apiv1.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
DeferCleanup(db.Init(GinkgoT().Context()))
realDS = persistence.New(db.Db())
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
Expect(err).ToNot(HaveOccurred())
specRouter, err = gorillamux.NewRouter(doc)
@ -46,6 +60,64 @@ func serve(h http.Handler, req *http.Request) *httptest.ResponseRecorder {
return w
}
// testClient drives a router end to end through serve, so every response is also checked against the spec.
type testClient struct {
ctx context.Context
router http.Handler
}
func (c testClient) call(method, path, bearer string, body any) *httptest.ResponseRecorder {
if body == nil {
return c.callRaw(method, path, bearer, "")
}
b, _ := json.Marshal(body)
return c.callRaw(method, path, bearer, string(b))
}
// callRaw sends body verbatim, for JSON a map cannot express, like keys differing only in case.
func (c testClient) callRaw(method, path, bearer, body string) *httptest.ResponseRecorder {
var req *http.Request
if body != "" {
req = httptest.NewRequestWithContext(c.ctx, method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequestWithContext(c.ctx, method, path, nil)
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
return serve(c.router, req)
}
func (c testClient) setup() GrantCreated {
w := c.call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw"))
ExpectWithOffset(1, w.Code).To(Equal(http.StatusCreated), w.Body.String())
var gc GrantCreated
decodeJSON(w, &gc)
return gc
}
// login signs in as the admin created by setup; nil scopes asks for all of them.
func (c testClient) login(scopes []string) GrantCreated {
body := creds("admin", "pw")
if scopes != nil {
body["scopes"] = scopes
}
w := c.call(http.MethodPost, "/api/v1/auth/login", "", body)
ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK), w.Body.String())
var gc GrantCreated
decodeJSON(w, &gc)
return gc
}
func creds(user, pw string) map[string]any {
return map[string]any{"username": user, "password": pw, "client": "TestApp", "clientVersion": "1.0"}
}
func decodeJSON(w *httptest.ResponseRecorder, v any) {
ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), v)).To(Succeed(), w.Body.String())
}
func validateAgainstSpec(req *http.Request, w *httptest.ResponseRecorder) {
route, pathParams, err := specRouter.FindRoute(req)
if errors.Is(err, routers.ErrPathNotFound) || errors.Is(err, routers.ErrMethodNotAllowed) {

View file

@ -0,0 +1,90 @@
package apiv1
import (
"cmp"
"context"
"errors"
"github.com/navidrome/navidrome/core/apiauth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
)
const defaultPageSize = 100
// Login relies on model.ErrInvalidAuth mapping to a detail-less 401, so unknown user and wrong password look the same.
func (rt *Router) Login(ctx context.Context, req LoginRequestObject) (LoginResponseObject, error) {
b := *req.Body
issued, err := rt.auth.Login(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes))
if err != nil {
return nil, err
}
return Login200JSONResponse{Body: toGrantCreated(issued)}, nil
}
func (rt *Router) SetupFirstAdmin(ctx context.Context, req SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error) {
b := *req.Body
issued, err := rt.auth.Setup(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes))
if err != nil {
return nil, err
}
return SetupFirstAdmin201JSONResponse{Body: toGrantCreated(issued)}, nil
}
func (rt *Router) ChangePassword(ctx context.Context, req ChangePasswordRequestObject) (ChangePasswordResponseObject, error) {
p, err := principalFrom(ctx)
if err != nil {
return nil, err
}
b := *req.Body
revoke := true
if b.RevokeOtherGrants != nil {
revoke = *b.RevokeOtherGrants
}
err = rt.auth.ChangePassword(ctx, p, b.CurrentPassword, b.NewPassword, revoke)
if errors.Is(err, apiauth.ErrCurrentPasswordMismatch) {
return nil, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"})
}
if err != nil {
return nil, err
}
return ChangePassword204Response{}, nil
}
func (rt *Router) ListGrants(ctx context.Context, req ListGrantsRequestObject) (ListGrantsResponseObject, error) {
p, err := principalFrom(ctx)
if err != nil {
return nil, err
}
offset := gg.V(req.Params.OffsetParam)
limit := cmp.Or(gg.V(req.Params.LimitParam), defaultPageSize)
grants, total, err := rt.auth.ListGrants(ctx, p, offset, limit)
if err != nil {
return nil, err
}
items := slice.Map(grants, func(g model.Grant) Grant { return toGrant(g, p.GrantID) })
return ListGrants200JSONResponse{Items: items, Total: int(total), Offset: offset, Limit: limit}, nil
}
func (rt *Router) RevokeGrant(ctx context.Context, req RevokeGrantRequestObject) (RevokeGrantResponseObject, error) {
p, err := principalFrom(ctx)
if err != nil {
return nil, err
}
if err := rt.auth.RevokeGrant(ctx, p, req.Id); err != nil {
return nil, err
}
return RevokeGrant204Response{}, nil
}
func (rt *Router) Logout(ctx context.Context, _ LogoutRequestObject) (LogoutResponseObject, error) {
p, err := principalFrom(ctx)
if err != nil {
return nil, err
}
if err := rt.auth.Logout(ctx, p); err != nil {
return nil, err
}
return Logout200JSONResponse{LogoutUrl: nil}, nil
}

View file

@ -0,0 +1,287 @@
package apiv1
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"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 _ = Describe("auth endpoints", func() {
var ctx context.Context
var api testClient
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 0
resetDB()
api = testClient{ctx: ctx, router: New(realDS)}
})
It("lets exactly one of a v1 setup and a v0 first-admin creation win", func() {
var wg sync.WaitGroup
var v1Code int
var v0Err error
wg.Add(2)
go func() {
defer GinkgoRecover()
defer wg.Done()
v1Code = api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("v1admin", "pw")).Code
}()
go func() {
defer GinkgoRecover()
defer wg.Done()
_, v0Err = auth.CreateFirstAdmin(ctx, realDS, "v0admin", "pw", nil) // what v0 /auth/createAdmin runs
}()
wg.Wait()
Expect(realDS.User().CountAll(ctx)).To(Equal(int64(1)))
Expect(v1Code).To(Or(Equal(http.StatusCreated), Equal(http.StatusConflict)))
Expect(v1Code == http.StatusCreated).ToNot(Equal(v0Err == nil), "exactly one must win")
})
It("sets up the first admin once, then answers 409 setup_complete", func() {
gc := api.setup()
Expect(gc.Secret).To(HavePrefix("ndg_"))
Expect(gc.User.IsAdmin).To(BeTrue())
Expect(gc.Grant.Provider).To(Equal("setup"))
Expect(gc.Grant.Current).To(BeTrue())
w := api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("second", "pw"))
Expect(w.Code).To(Equal(http.StatusConflict))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeSetupComplete))
})
It("logs in and uses the grant secret on a scoped endpoint", func() {
api.setup()
w := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("ADMIN", "pw"))
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
var gc GrantCreated
decodeJSON(w, &gc)
Expect(gc.User.PasswordChangeable).To(BeTrue())
w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
var list GrantList
decodeJSON(w, &list)
Expect(list.Total).To(Equal(2))
Expect(list.Limit).To(Equal(100))
})
It("fails login the same way for an unknown user and a wrong password, with a Bearer challenge", func() {
api.setup()
a := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "wrong"))
b := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("ghost", "pw"))
Expect(a.Code).To(Equal(http.StatusUnauthorized))
Expect(a.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
Expect(a.Body.String()).To(Equal(b.Body.String()))
})
It("treats missing scopes as all scopes, and [] as no scopes", func() {
api.setup()
all := api.login(nil)
Expect(all.Grant.Scopes).To(ConsistOf(ScopeAll))
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", all.Secret, nil).Code).To(Equal(http.StatusOK))
none := api.login([]string{})
Expect(none.Grant.Scopes).To(BeEmpty())
w := api.call(http.MethodGet, "/api/v1/auth/grants", none.Secret, nil)
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="read"`))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope))
})
It("drops unknown requested scopes instead of rejecting them", func() {
api.setup()
gc := api.login([]string{"read", "playlists:write"})
Expect(gc.Grant.Scopes).To(ConsistOf(ScopeRead))
})
It("does not let a grant without read log out or revoke grants", func() {
gc := api.setup()
narrow := api.login([]string{"password"})
Expect(api.call(http.MethodPost, "/api/v1/auth/logout", narrow.Secret, nil).Code).To(Equal(http.StatusForbidden))
Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.Secret, nil).Code).To(Equal(http.StatusForbidden))
})
It("logs out: the secret stops working and logoutUrl is null", func() {
gc := api.setup()
w := api.call(http.MethodPost, "/api/v1/auth/logout", gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`))
w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
})
It("revokes another grant of the caller, whose secret then stops working", func() {
gc := api.setup()
other := api.login(nil)
Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+other.Grant.Id, gc.Secret, nil).Code).To(Equal(http.StatusNoContent))
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized))
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK))
})
It("challenges with invalid_token when the grant is revoked while a password change runs", func() {
gc := api.setup()
revoking := testClient{ctx: ctx, router: New(beforeTxDS{DataStore: realDS, before: func() {
Expect(realDS.Grant().DeleteForUser(ctx, gc.User.Id, gc.Grant.Id)).To(Succeed())
}})}
w := revoking.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "pw", "newPassword": "pw2"})
Expect(w.Code).To(Equal(http.StatusUnauthorized), w.Body.String())
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
api.login(nil)
})
It("rejects a case-variant scopes key that would widen an explicit empty subset", func() {
api.setup()
w := api.callRaw(http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"pw","client":"c","scopes":[],"Scopes":null}`)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "Scopes", Message: "must match the field name exactly"}))
})
It("rejects a case-variant client key that would skip its length limit", func() {
api.setup()
body := `{"username":"admin","password":"pw","client":"ok","Client":"` + strings.Repeat("x", 60_000) + `"}`
w := api.callRaw(http.MethodPost, "/api/v1/auth/login", "", body)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "Client", Message: "must match the field name exactly"}))
})
DescribeTable("rejects a body with data after its JSON value, without echoing it",
func(path string, needsSecret bool, body string) {
secret := ""
if gc := api.setup(); needsSecret {
secret = gc.Secret
}
w := api.callRaw(http.MethodPost, path, secret, body)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "", Message: "must be a single JSON value"}))
Expect(w.Body.String()).ToNot(ContainSubstring("hunter2"))
},
Entry("login with a trailing byte", "/api/v1/auth/login", false, `{"username":"a","password":"hunter2","client":"c"}x`),
Entry("login with a second value", "/api/v1/auth/login", false, `{"username":"a","password":"hunter2","client":"c"} {}`),
// This schema has a default, which the validator must not fill in by rewriting the body.
Entry("password change with a trailing byte", "/api/v1/auth/password", true, `{"currentPassword":"hunter2","newPassword":"pw2"}x`),
)
It("checks the login body even when Content-Type has a repeated parameter", func() {
api.setup()
body := `{"username":"admin","password":"pw","client":"c","scopes":[],"Scopes":null}`
req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json; a=1; a=2")
w := serve(api.router, req)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "Scopes", Message: "must match the field name exactly"}))
})
It("marks responses carrying a grant secret no-store", func() {
w := api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw"))
Expect(w.Code).To(Equal(http.StatusCreated))
Expect(w.Header().Get("Cache-Control")).To(Equal("no-store"))
var gc GrantCreated
decodeJSON(w, &gc)
w = api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw"))
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Cache-Control")).To(Equal("no-store"))
w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Cache-Control")).To(BeEmpty())
})
It("answers 404 for a grant id the caller does not own, and 400 for an over-long id", func() {
gc := api.setup()
Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/does-not-exist", gc.Secret, nil).Code).To(Equal(http.StatusNotFound))
w := api.call(http.MethodDelete, "/api/v1/auth/grants/"+strings.Repeat("x", 65), gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"}))
})
It("changes the password, keeping the caller and revoking the rest", func() {
gc := api.setup()
other := api.login(nil)
w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "pw", "newPassword": "pw2"})
Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String())
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK))
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized))
})
It("keeps every grant when revokeOtherGrants is false", func() {
gc := api.setup()
other := api.login(nil)
body := map[string]any{"currentPassword": "pw", "newPassword": "pw2", "revokeOtherGrants": false}
w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, body)
Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String())
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK))
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusOK))
})
It("reports a wrong current password as a field error", func() {
gc := api.setup()
w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "nope", "newPassword": "pw2"})
Expect(w.Code).To(Equal(http.StatusBadRequest))
p := decodeProblem(w)
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"}))
})
DescribeTable("rejects bad credential bodies with a field error and no echo",
func(body map[string]any, field string) {
w := api.call(http.MethodPost, "/api/v1/auth/setup", "", body)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ContainElement(HaveField("Field", field)))
Expect(w.Body.String()).ToNot(ContainSubstring("hunter2"))
},
Entry("missing client", map[string]any{"username": "a", "password": "hunter2"}, "client"),
Entry("empty password", map[string]any{"username": "a", "password": "", "client": "hunter2"}, "password"),
Entry("client too long", map[string]any{"username": "a", "password": "hunter2", "client": strings.Repeat("x", 65)}, "client"),
Entry("bad scope format", map[string]any{"username": "a", "password": "hunter2", "client": "c", "scopes": []string{"NOT OK"}}, "scopes.0"),
)
DescribeTable("rejects a body over 1 MiB with 413",
func(body func(string) io.Reader) {
big := `{"username":"a","password":"` + strings.Repeat("a", maxBodyBytes) + `","client":"c"}`
req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/login", body(big))
req.Header.Set("Content-Type", "application/json")
w := serve(api.router, req)
Expect(w.Code).To(Equal(http.StatusRequestEntityTooLarge))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodePayloadTooLarge))
},
Entry("with a declared length", func(s string) io.Reader { return strings.NewReader(s) }),
// io.MultiReader hides the length, so the request has ContentLength -1, like a chunked upload.
Entry("with no declared length", func(s string) io.Reader { return io.MultiReader(strings.NewReader(s)) }),
)
})
// beforeTxDS calls before as each immediate transaction starts; authentication opens none, so it lands after the gate.
type beforeTxDS struct {
model.DataStore
before func()
}
func (d beforeTxDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error {
d.before()
return d.DataStore.WithTxImmediate(block, scope...)
}

13
server/apiv1/db_test.go Normal file
View file

@ -0,0 +1,13 @@
package apiv1
import (
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
)
var realDS model.DataStore
func resetDB() {
_, _ = db.Db().Exec("delete from api_grant")
_, _ = db.Db().Exec("delete from user")
}

71
server/apiv1/dto.go Normal file
View file

@ -0,0 +1,71 @@
package apiv1
import (
"context"
"github.com/navidrome/navidrome/core/apiauth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
)
func toScopes(in []string) []Scope {
return slice.Map(in, func(s string) Scope { return Scope(s) })
}
// fromScopeRequests keeps nil (all scopes) apart from an empty list (no scopes).
func fromScopeRequests(in *[]ScopeRequest) []string {
if in == nil {
return nil
}
return append([]string{}, *in...)
}
func nullable(s string) *string {
if s == "" {
return nil
}
return &s
}
func toGrant(g model.Grant, currentID string) Grant {
return Grant{
Id: g.ID,
Name: g.Name,
Client: g.Client,
ClientVersion: nullable(g.ClientVersion),
Scopes: toScopes(g.Scopes),
Provider: g.Provider,
CreatedAt: g.CreatedAt,
LastUsedAt: g.LastUsedAt,
LastUsedIp: nullable(g.LastUsedIP),
Current: g.ID == currentID,
}
}
func toGrantCreated(i *apiauth.Issued) GrantCreated {
return GrantCreated{
Secret: i.Secret,
Grant: toGrant(i.Grant, i.Grant.ID),
User: AuthUser{
Id: i.User.ID,
UserName: i.User.UserName,
Name: i.User.Name,
IsAdmin: i.User.IsAdmin,
PasswordChangeable: apiauth.PasswordChangeable(i.User),
},
}
}
func clientMeta(c CredentialsRequest) apiauth.ClientMeta {
return apiauth.ClientMeta{Client: c.Client, Name: gg.V(c.Name), ClientVersion: gg.V(c.ClientVersion)}
}
// principalFrom fails closed if the gate did not attach a principal to the context.
func principalFrom(ctx context.Context) (*apiauth.Principal, error) {
p, ok := apiauth.PrincipalFrom(ctx)
if !ok || p == nil {
return nil, model.ErrInvalidAuth
}
return p, nil
}

15
server/apiv1/dto_test.go Normal file
View file

@ -0,0 +1,15 @@
package apiv1
import (
"github.com/navidrome/navidrome/core/apiauth"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("toScopes", func() {
It("only produces scopes the spec's Scope enum allows", func() {
for _, s := range toScopes(append([]string{apiauth.ScopeAll}, apiauth.KnownScopes...)) {
Expect(s.Valid()).To(BeTrue(), "scope %q is missing from the spec's Scope enum", s)
}
})
})

422
server/apiv1/gate.go Normal file
View file

@ -0,0 +1,422 @@
package apiv1
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/getkin/kin-openapi/routers"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/apiauth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
)
type authenticator interface {
Authenticate(ctx context.Context, secret, ip string) (*apiauth.Principal, error)
}
type gateOp struct {
route *routers.Route
public bool
scope string
limited bool
noStore bool
}
func (o *gateOp) id() string { return o.route.Operation.OperationID }
type opKey struct{ method, path string }
type gate struct {
mux chi.Routes
ops map[opKey]*gateOp
auth authenticator
limiter func(http.Handler) http.Handler
}
// Modules that ride another module's scope; every other module's scope is its own name.
var moduleScope = map[string]string{
"core": apiauth.ScopeRead,
"transcoding": "streaming",
"custom-tags": apiauth.ScopeRead,
"grouping": apiauth.ScopeRead,
"smart-playlists": "playlists:write",
}
type gateRules struct {
limited map[string]bool // login-type operations, throttled per client IP
noScope map[string]bool // the only bearerAuth operations allowed without x-scope
noStore map[string]bool // operations whose responses carry a secret
}
func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRules) (*gate, error) {
g := &gate{mux: mux, ops: map[opKey]*gateOp{}, auth: auth}
ids := map[string]bool{}
for path, item := range doc.Paths.Map() {
for method, op := range item.Operations() {
gop, err := buildGateOp(doc, path, item, method, op, rules)
if err != nil {
return nil, err
}
g.ops[opKey{method, path}] = gop
ids[op.OperationID] = true
}
}
if err := rules.check(ids); err != nil {
return nil, err
}
if conf.Server.AuthRequestLimit > 0 {
g.limiter = server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength,
// Counts are per node, so X-RateLimit-Remaining would mislead clients of a scaled-out server.
httprate.WithResponseHeaders(httprate.ResponseHeaders{RetryAfter: "Retry-After"}),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
writeProblemStatus(w, r, http.StatusTooManyRequests, ProblemCodeRateLimited, "too many requests")
}))
}
return g, nil
}
// check fails on a rule naming an operation the spec lacks, so a typo cannot silently disable the rule.
func (rules gateRules) check(ids map[string]bool) error {
sets := map[string]map[string]bool{"limited": rules.limited, "noScope": rules.noScope, "noStore": rules.noStore}
for name, set := range sets {
for id := range set {
if !ids[id] {
return fmt.Errorf("gate rule %s names unknown operation %s", name, id)
}
}
}
return nil
}
// buildGateOp enforces the allowed security forms, so a spec edit cannot silently drop a requirement.
func buildGateOp(doc *openapi3.T, path string, item *openapi3.PathItem, method string, op *openapi3.Operation, rules gateRules) (*gateOp, error) {
id := op.OperationID
gop := &gateOp{
route: &routers.Route{Spec: doc, Path: path, PathItem: item, Method: method, Operation: op},
limited: rules.limited[id],
noStore: rules.noStore[id],
}
if op.Security == nil {
return nil, fmt.Errorf("operation %s must declare security explicitly", id)
}
rawScope, hasScope := op.Extensions["x-scope"]
scope, isString := rawScope.(string)
if hasScope && (!isString || scope == "") {
return nil, fmt.Errorf("operation %s: x-scope must be a non-empty string", id)
}
module, _ := op.Extensions["x-module"].(string)
switch reqs := *op.Security; {
case len(reqs) == 0:
gop.public = true
case len(reqs) == 1 && isScheme(reqs[0], "bearerAuth"):
default:
return nil, fmt.Errorf("operation %s has a security requirement outside the allowed forms", id)
}
if !gop.public && scope == "" && !rules.noScope[id] {
return nil, fmt.Errorf("operation %s: bearerAuth needs x-scope", id)
}
if scope != "" {
if gop.public {
return nil, fmt.Errorf("operation %s: x-scope needs bearerAuth", id)
}
base := cmp.Or(moduleScope[module], module)
if scope != base && scope != base+":write" {
return nil, fmt.Errorf("operation %s: x-scope %q does not match module %q", id, scope, module)
}
if !slices.Contains(apiauth.KnownScopes, scope) {
return nil, fmt.Errorf("operation %s: unknown x-scope %q", id, scope)
}
}
gop.scope = scope
return gop, nil
}
// isScheme requires the scheme alone with an empty scope list, as OpenAPI 3.0.3 demands for http schemes.
func isScheme(req openapi3.SecurityRequirement, name string) bool {
scopes, ok := req[name]
return ok && len(req) == 1 && len(scopes) == 0
}
// checkRoutes fails when a routed pattern has no spec operation or a spec operation has no route.
func (g *gate) checkRoutes() error {
err := chi.Walk(g.mux, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
if _, ok := g.ops[opKey{method, route}]; !ok {
return fmt.Errorf("route %s %s is not in the spec", method, route)
}
return nil
})
if err != nil {
return err
}
for _, op := range g.ops {
if g.mux.Find(chi.NewRouteContext(), op.route.Method, op.route.Path) != op.route.Path {
return fmt.Errorf("spec operation %s (%s %s) has no route", op.id(), op.route.Method, op.route.Path)
}
}
return nil
}
func (g *gate) handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method := routeMethod(r)
rctx := chi.NewRouteContext()
pattern := g.mux.Find(rctx, method, routePath(r))
if pattern == "" {
next.ServeHTTP(w, r)
return
}
op, ok := g.ops[opKey{method, pattern}]
if !ok {
log.Error(r.Context(), "API v1: routed pattern missing from the spec", "method", method, "pattern", pattern)
writeProblemStatus(w, r, http.StatusInternalServerError, ProblemCodeInternal, "")
return
}
if op.noStore {
w.Header().Set("Cache-Control", "no-store")
}
serve := func(w http.ResponseWriter, r *http.Request) {
r, ok := g.authorize(w, r, op)
if !ok {
return
}
if !g.validate(w, r, op, rctx) {
return
}
next.ServeHTTP(w, r)
}
if op.limited && g.limiter != nil {
g.limiter(http.HandlerFunc(serve)).ServeHTTP(w, r)
return
}
serve(w, r)
})
}
func (g *gate) authorize(w http.ResponseWriter, r *http.Request, op *gateOp) (*http.Request, bool) {
if op.public {
return r, true
}
secret, ok := bearerToken(r)
if !ok {
writeProblemStatus(w, r, http.StatusUnauthorized, ProblemCodeUnauthorized, "")
return r, false
}
p, err := g.auth.Authenticate(r.Context(), secret, server.ClientAddr(r))
if err != nil {
writeProblem(w, r, err)
return r, false
}
if op.scope != "" && !apiauth.Satisfies(p.Scopes, op.scope) {
writeProblem(w, r, &scopeError{scope: op.scope})
return r, false
}
ctx := apiauth.WithPrincipal(request.WithUser(r.Context(), p.User), p)
return r.WithContext(ctx), true
}
func bearerToken(r *http.Request) (string, bool) {
scheme, token, ok := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ")
token = strings.TrimSpace(token)
if !ok || !strings.EqualFold(scheme, "Bearer") || token == "" {
return "", false
}
return token, true
}
// SkipSettingDefaults: the handlers apply defaults themselves, and the validator must not rewrite the body.
var validationOptions = &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, MultiError: true, SkipSettingDefaults: true}
func (g *gate) validate(w http.ResponseWriter, r *http.Request, op *gateOp, rctx *chi.Context) bool {
params := make(map[string]string, len(rctx.URLParams.Keys))
for i, k := range rctx.URLParams.Keys {
params[k] = rctx.URLParams.Values[i]
}
body, err := readBody(r, op.route.Operation)
if err == nil {
err = openapi3filter.ValidateRequest(r.Context(), &openapi3filter.RequestValidationInput{
Request: r, PathParams: params, Route: op.route, Options: validationOptions,
})
if body != nil {
r.Body = io.NopCloser(bytes.NewReader(body))
}
}
if tooLarge(err) {
writeProblem(w, r, ClientError(err, tooLargeDetail))
return false
}
var fields []ValidationError
if err != nil {
fields = sanitizeValidation(err)
} else if fields = jsonBodyFields(body, op.route.Operation); len(fields) == 0 {
return true
}
log.Debug(r.Context(), "API v1: request failed validation", "operation", op.id(), "errors", fields)
writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "the request does not match the API schema", fields...)
return false
}
// readBody reads a declared body once, so the validator, the JSON checks and the handler all see the same bytes.
func readBody(r *http.Request, op *openapi3.Operation) ([]byte, error) {
if op.RequestBody == nil || r.Body == nil || r.Body == http.NoBody {
return nil, nil
}
data, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
r.Body = io.NopCloser(bytes.NewReader(data))
return data, nil
}
// jsonBodyFields checks what kin-openapi misses in a JSON body: data after the first value, which Go's decoder
// ignores, and keys that only case-fold to a declared property, which encoding/json decodes into that property.
func jsonBodyFields(data []byte, op *openapi3.Operation) []ValidationError {
if op.RequestBody == nil || op.RequestBody.Value == nil || len(bytes.TrimSpace(data)) == 0 {
return nil
}
// Keyed on the spec, not the request's Content-Type: the handlers decode JSON whatever the header says.
media := op.RequestBody.Value.Content.Get("application/json")
if media == nil || media.Schema == nil {
return nil
}
dec := json.NewDecoder(bytes.NewReader(data))
var body any
if err := dec.Decode(&body); err != nil {
return []ValidationError{{Field: "", Message: "must be a single JSON value"}}
}
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
return []ValidationError{{Field: "", Message: "must be a single JSON value"}}
}
var out []ValidationError
collectCaseAliases(body, media.Schema.Value, "", &out)
return out
}
func collectCaseAliases(v any, schema *openapi3.Schema, path string, out *[]ValidationError) {
if schema == nil {
return
}
switch v := v.(type) {
case map[string]any:
for _, key := range slices.Sorted(maps.Keys(v)) {
field := joinField(path, key)
if prop, ok := schema.Properties[key]; ok {
if prop != nil {
collectCaseAliases(v[key], prop.Value, field, out)
}
continue
}
for name := range schema.Properties {
if strings.EqualFold(key, name) {
*out = append(*out, ValidationError{Field: field, Message: "must match the field name exactly"})
break
}
}
}
case []any:
if schema.Items == nil {
return
}
for i, item := range v {
collectCaseAliases(item, schema.Items.Value, joinField(path, strconv.Itoa(i)), out)
}
}
}
func joinField(path, name string) string {
if path == "" {
return name
}
return path + "." + name
}
var missingProperty = regexp.MustCompile(`property "([^"]+)" is missing`)
// sanitizeValidation keeps only field paths and fixed messages: kin-openapi errors can embed the submitted value.
// It walks wrappers by concrete type, not errors.As, because MultiError.As would skip the RequestError that names the parameter.
func sanitizeValidation(err error) []ValidationError {
var out []ValidationError
var walk func(err error, param string)
walk = func(err error, param string) {
switch e := err.(type) { //nolint:errorlint
case openapi3.MultiError:
for _, child := range e {
walk(child, param)
}
case *openapi3filter.RequestError:
if e.Parameter != nil {
param = e.Parameter.Name
}
switch {
case errors.Is(e.Err, openapi3filter.ErrInvalidRequired), errors.Is(e.Err, openapi3filter.ErrInvalidEmptyValue):
out = append(out, ValidationError{Field: param, Message: "is required"})
case e.Err != nil:
walk(e.Err, param)
default:
out = append(out, ValidationError{Field: param, Message: "is invalid"})
}
case *openapi3.SchemaError:
field := strings.Join(e.JSONPointer(), ".")
if field == "" && e.SchemaField == "required" {
if m := missingProperty.FindStringSubmatch(e.Reason); m != nil {
field = m[1]
}
}
switch {
case param != "" && field != "":
field = param + "." + field
case field == "":
field = param
}
out = append(out, ValidationError{Field: field, Message: schemaMessage(e.SchemaField)})
default:
if inner := errors.Unwrap(err); inner != nil {
walk(inner, param)
return
}
out = append(out, ValidationError{Field: param, Message: "is invalid"})
}
}
walk(err, "")
return out
}
func schemaMessage(keyword string) string {
switch keyword {
case "required":
return "is required"
case "maxLength", "maxItems":
return "is too long"
case "minLength", "minItems":
return "is too short"
case "maximum", "exclusiveMaximum":
return "is too large"
case "minimum", "exclusiveMinimum":
return "is too small"
case "pattern", "format":
return "has an invalid format"
case "enum":
return "is not an allowed value"
case "type", "nullable":
return "has the wrong type"
default:
return "is invalid"
}
}

406
server/apiv1/gate_test.go Normal file
View file

@ -0,0 +1,406 @@
package apiv1
import (
"bytes"
"context"
"maps"
"net/http"
"net/http/httptest"
"os"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/apiauth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const gateSpec = `
openapi: 3.0.3
info: {title: t, version: "1"}
paths:
/open:
get: {operationId: open, x-module: core, security: [], responses: {'200': {description: ok}}}
/things/{id}:
get:
operationId: getThing
x-module: core
x-scope: read
security: [{bearerAuth: []}]
parameters: [{name: id, in: path, required: true, schema: {type: string, maxLength: 3}}]
responses: {'200': {description: ok}}
/things:
post:
operationId: createThing
x-module: password
x-scope: password
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: {type: string, maxLength: 5}
tags: {type: array, items: {type: object, properties: {label: {type: string, maxLength: 5}}}}
responses: {'200': {description: ok}}
/caps:
get: {operationId: caps, x-module: core, security: [{bearerAuth: []}], responses: {'200': {description: ok}}}
/limited:
post: {operationId: limited, x-module: core, security: [], responses: {'200': {description: ok}}}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer}
`
type fakeAuth struct {
principal *apiauth.Principal
err error
gotSecret string
gotIP string
}
func (f *fakeAuth) Authenticate(_ context.Context, secret, ip string) (*apiauth.Principal, error) {
f.gotSecret, f.gotIP = secret, ip
return f.principal, f.err
}
var testGateRules = gateRules{
limited: map[string]bool{"limited": true},
noScope: map[string]bool{"caps": true},
noStore: map[string]bool{"caps": true},
}
var _ = Describe("spec gate", func() {
var ctx context.Context
var fa *fakeAuth
var mux *chi.Mux
var g *gate
var reached string
build := func(spec string) (*chi.Mux, error) {
doc, err := openapi3.NewLoader().LoadFromData([]byte(spec))
Expect(err).ToNot(HaveOccurred())
m := chi.NewRouter()
g, err = newGate(doc, m, fa, testGateRules)
if err != nil {
return nil, err
}
m.Use(headAsGet(m), g.handler)
ok := func(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
reached = name
if p, found := apiauth.PrincipalFrom(r.Context()); found {
w.Header().Set("X-User", p.User.ID)
}
w.WriteHeader(http.StatusOK)
}
}
m.Get("/open", ok("open"))
m.Get("/things/{id}", ok("getThing"))
m.Post("/things", ok("createThing"))
m.Get("/caps", ok("caps"))
m.Post("/limited", ok("limited"))
return m, nil
}
do := func(method, path, auth, body string) *httptest.ResponseRecorder {
var req *http.Request
if body != "" {
req = httptest.NewRequestWithContext(ctx, method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequestWithContext(ctx, method, path, nil)
}
if auth != "" {
req.Header.Set("Authorization", auth)
}
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
return w
}
BeforeEach(func() {
ctx = GinkgoT().Context()
reached = ""
fa = &fakeAuth{principal: &apiauth.Principal{User: model.User{ID: "u1"}, GrantID: "g1", Scopes: []string{"read"}}}
var err error
mux, err = build(gateSpec)
Expect(err).ToNot(HaveOccurred())
})
It("lets public operations through without a credential", func() {
Expect(do(http.MethodGet, "/open", "", "").Code).To(Equal(http.StatusOK))
Expect(reached).To(Equal("open"))
})
It("requires a grant secret, with a Bearer challenge", func() {
w := do(http.MethodGet, "/things/1", "", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized))
Expect(reached).To(BeEmpty())
})
It("accepts the Bearer scheme in any case and trims spaces", func() {
w := do(http.MethodGet, "/things/1", "bearer ndg_secret ", "")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.gotSecret).To(Equal("ndg_secret"))
Expect(w.Header().Get("X-User")).To(Equal("u1"))
})
It("maps auth failures to unauthorized with invalid_token", func() {
fa.err = model.ErrInvalidAuth
w := do(http.MethodGet, "/things/1", "Bearer x", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized))
})
It("rejects a grant without the operation's scope", func() {
w := do(http.MethodPost, "/things", "Bearer x", `{"name":"a"}`)
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="password"`))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope))
})
It("lets any valid grant through an operation with no x-scope", func() {
fa.principal.Scopes = nil
Expect(do(http.MethodGet, "/caps", "Bearer x", "").Code).To(Equal(http.StatusOK))
})
It("passes the full client address to the authenticator, not the rate-limit /64", func() {
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/things/1", nil)
req.RemoteAddr = "[2001:db8:1:2:3:4:5:6]:4321"
req.Header.Set("Authorization", "Bearer x")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.gotIP).To(Equal("2001:db8:1:2:3:4:5:6"))
})
It("marks only the listed operations' responses no-store, errors included", func() {
Expect(do(http.MethodGet, "/caps", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store"))
fa.err = model.ErrInvalidAuth
Expect(do(http.MethodGet, "/caps", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store"))
fa.err = nil
Expect(do(http.MethodGet, "/things/1", "Bearer x", "").Header().Get("Cache-Control")).To(BeEmpty())
})
It("checks HEAD on a protected GET", func() {
w := do(http.MethodHead, "/things/1", "", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("looks routes up on the raw path, as chi dispatches them", func() {
w := do(http.MethodGet, "/things/a%2Fb", "", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(reached).To(BeEmpty())
root := chi.NewRouter()
root.Mount("/music/api/v1", mux)
w = httptest.NewRecorder()
root.ServeHTTP(w, httptest.NewRequestWithContext(ctx, http.MethodGet, "/music/api/v1/things/a%2Fb", nil))
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(reached).To(BeEmpty())
})
It("works when mounted under a base path", func() {
root := chi.NewRouter()
root.Mount("/music/api/v1", mux)
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/music/api/v1/things/1", nil)
w := httptest.NewRecorder()
root.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("authenticates before validating", func() {
w := do(http.MethodPost, "/things", "", `{"name":"far-too-long"}`)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("returns and logs sanitised validation errors that never echo the value", func() {
logs := captureLogs()
fa.principal.Scopes = []string{"password"}
w := do(http.MethodPost, "/things", "Bearer x", `{"name":"hunter2-secret"}`)
Expect(w.Code).To(Equal(http.StatusBadRequest))
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "name", Message: "is too long"}))
Expect(w.Body.String()).ToNot(ContainSubstring("hunter2"))
Expect(logs.String()).To(ContainSubstring("failed validation"))
Expect(logs.String()).ToNot(ContainSubstring("hunter2"))
})
It("reports a missing required body field by name", func() {
fa.principal.Scopes = []string{"password"}
w := do(http.MethodPost, "/things", "Bearer x", `{}`)
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "name", Message: "is required"}))
})
DescribeTable("rejects a case variant of a declared body field, which Go would decode into it",
func(body, field string) {
fa.principal.Scopes = []string{"password"}
w := do(http.MethodPost, "/things", "Bearer x", body)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: field, Message: "must match the field name exactly"}))
Expect(reached).To(BeEmpty())
},
Entry("top level", `{"name":"ok","NAME":"much-too-long"}`, "NAME"),
Entry("inside array items", `{"name":"ok","tags":[{"label":"a"},{"label":"b","Label":"much-too-long"}]}`, "tags.1.Label"),
Entry("Unicode case folding", "{\"name\":\"ok\",\"tag\u017f\":null}", "tag\u017f"),
)
DescribeTable("rejects data after the first JSON value, which Go's decoder would ignore",
func(body string) {
fa.principal.Scopes = []string{"password"}
w := do(http.MethodPost, "/things", "Bearer x", body)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "", Message: "must be a single JSON value"}))
Expect(reached).To(BeEmpty())
},
Entry("garbage", `{"name":"ok","NAME":"much-too-long"}x`),
Entry("a second value", `{"name":"ok"} {"NAME":"much-too-long"}`),
Entry("a stray bracket", `{"name":"ok"}]`),
)
DescribeTable("checks the body whatever parameters the Content-Type carries",
func(contentType string) {
fa.principal.Scopes = []string{"password"}
req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/things", strings.NewReader(`{"name":"ok","NAME":"much-too-long"}`))
req.Header.Set("Content-Type", contentType)
req.Header.Set("Authorization", "Bearer x")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "NAME", Message: "must match the field name exactly"}))
Expect(reached).To(BeEmpty())
},
Entry("repeated parameter", "application/json; a=1; a=2"),
Entry("repeated charset", "application/json; charset=utf-8; CHARSET=latin1"),
)
It("accepts trailing whitespace after the JSON value", func() {
fa.principal.Scopes = []string{"password"}
Expect(do(http.MethodPost, "/things", "Bearer x", "{\"name\":\"ok\"}\n \t").Code).To(Equal(http.StatusOK))
})
It("allows unknown body fields that do not collide with a declared one", func() {
fa.principal.Scopes = []string{"password"}
w := do(http.MethodPost, "/things", "Bearer x", `{"name":"ok","extra":{"Name":"x"},"tags":[{"label":"a","other":1}]}`)
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
Expect(reached).To(Equal("createThing"))
})
It("validates path parameters", func() {
w := do(http.MethodGet, "/things/toolong", "Bearer x", "")
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"}))
})
It("passes unknown paths through to the router's 404", func() {
Expect(do(http.MethodGet, "/nope", "", "").Code).To(Equal(http.StatusNotFound))
})
It("fails closed for a routed pattern the spec does not know", func() {
mux.Get("/extra", func(w http.ResponseWriter, r *http.Request) { reached = "extra" })
w := do(http.MethodGet, "/extra", "", "")
Expect(w.Code).To(Equal(http.StatusInternalServerError))
Expect(reached).To(BeEmpty())
})
It("rate-limits the listed operations with a 429 problem", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 1
var err error
mux, err = build(gateSpec)
Expect(err).ToNot(HaveOccurred())
w := do(http.MethodPost, "/limited", "", "")
Expect(w.Code).To(Equal(http.StatusOK))
expectNoXRateLimitHeaders(w)
w = do(http.MethodPost, "/limited", "", "")
Expect(w.Code).To(Equal(http.StatusTooManyRequests))
Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty())
expectNoXRateLimitHeaders(w)
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeRateLimited))
})
DescribeTable("refuses specs that break the security rules",
func(bad string) {
_, err := build(bad)
Expect(err).To(HaveOccurred())
},
Entry("missing security", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core,", 1)),
Entry("scope not matching module", strings.Replace(gateSpec, "x-scope: read", "x-scope: password", 1)),
Entry("unknown scope", strings.Replace(gateSpec, "x-scope: read", "x-scope: bogus", 1)),
Entry("bearer without x-scope outside the allowlist", strings.Replace(gateSpec, " x-scope: read\n", "", 1)),
Entry("a scheme other than bearerAuth", strings.Replace(
strings.Replace(gateSpec, " bearerAuth: {type: http, scheme: bearer}\n", " bearerAuth: {type: http, scheme: bearer}\n grantAuth: {type: http, scheme: bearer}\n", 1),
"operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{grantAuth: []}]", 1)),
Entry("an undeclared scheme", strings.Replace(gateSpec, "operationId: limited, x-module: core, security: []", "operationId: limited, x-module: core, security: [{grantAuth: []}]", 1)),
Entry("non-empty scope list on a bearer scheme", strings.Replace(gateSpec, "operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{bearerAuth: [read]}]", 1)),
Entry("x-scope on a public operation", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core, x-scope: read, security: [],", 1)),
Entry("x-scope that is not a string", strings.Replace(gateSpec, "x-scope: read", "x-scope: [read]", 1)),
Entry("x-scope not in KnownScopes", strings.Replace(gateSpec, "x-module: password\n x-scope: password", "x-module: admin\n x-scope: admin", 1)),
)
DescribeTable("refuses rules that name an operation missing from the spec",
func(set func(*gateRules) *map[string]bool) {
doc, err := openapi3.NewLoader().LoadFromData([]byte(gateSpec))
Expect(err).ToNot(HaveOccurred())
rules := testGateRules
m := set(&rules)
*m = maps.Clone(*m)
(*m)["typo"] = true
_, err = newGate(doc, chi.NewRouter(), fa, rules)
Expect(err).To(MatchError(ContainSubstring("typo")))
},
Entry("limited", func(r *gateRules) *map[string]bool { return &r.limited }),
Entry("noScope", func(r *gateRules) *map[string]bool { return &r.noScope }),
Entry("noStore", func(r *gateRules) *map[string]bool { return &r.noStore }),
)
It("checks routes against the spec in both directions", func() {
Expect(g.checkRoutes()).To(Succeed())
mux.Get("/extra", func(http.ResponseWriter, *http.Request) {})
Expect(g.checkRoutes()).To(MatchError(ContainSubstring("GET /extra is not in the spec")))
extraOp := strings.Replace(gateSpec, "components:", ` /unrouted:
get: {operationId: unrouted, x-module: core, security: [], responses: {'200': {description: ok}}}
components:`, 1)
_, err := build(extraOp)
Expect(err).ToNot(HaveOccurred())
Expect(g.checkRoutes()).To(MatchError(ContainSubstring("unrouted")))
})
})
// captureLogs sends debug logs to a buffer for the rest of the spec.
func captureLogs() *bytes.Buffer {
buf := &bytes.Buffer{}
log.SetOutput(buf)
log.SetLevel(log.LevelDebug)
DeferCleanup(func() {
log.SetOutput(os.Stderr)
log.SetLevel(log.LevelFatal)
})
return buf
}
func expectNoXRateLimitHeaders(w *httptest.ResponseRecorder) {
GinkgoHelper()
for _, h := range []string{"X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Increment", "X-RateLimit-Reset"} {
Expect(w.Header().Values(h)).To(BeEmpty(), h)
}
}

View file

@ -0,0 +1,12 @@
overlay: 1.0.0
info:
title: Go type names for the API v1 server
version: 1.0.0
actions:
# Ginkgo's dot-imported Offset would clash with a generated Offset type in this package's tests.
- target: $.components.parameters.offset
update:
x-go-name: OffsetParam
- target: $.components.parameters.limit
update:
x-go-name: LimitParam

View file

@ -8,5 +8,7 @@ output-options:
exclude-operation-ids:
- getOpenAPISpecJSON
- getOpenAPISpecYAML
overlay:
path: server/apiv1/oapi-codegen-overlay.yaml
compatibility:
always-prefix-enum-values: true

View file

@ -3,26 +3,89 @@ package apiv1
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/navidrome/navidrome/core/apiauth"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
const problemContentType = "application/problem+json"
type clientError struct {
err error
detail string
}
func (e *clientError) Error() string { return e.detail }
func (e *clientError) Unwrap() error { return e.err }
// ClientError marks detail as safe to show clients; err still decides the status and code.
func ClientError(err error, detail string) error {
return &clientError{err: err, detail: detail}
}
// scopeError names the scope an operation requires, for the insufficient_scope challenge.
type scopeError struct {
scope string
}
func (e *scopeError) Error() string { return "insufficient scope" }
const tooLargeDetail = "request body too large"
func tooLarge(err error) bool {
return errors.As(err, new(*http.MaxBytesError))
}
type fieldErrors struct {
fields []ValidationError
}
func (e *fieldErrors) Error() string { return "validation failed" }
func (e *fieldErrors) Unwrap() error { return model.ErrValidation }
func validationFailed(fields ...ValidationError) error {
return &fieldErrors{fields: fields}
}
func writeProblem(w http.ResponseWriter, r *http.Request, err error) {
status, code := classifyError(err)
detail := err.Error()
if status == http.StatusInternalServerError {
log.Error(r.Context(), "API v1: unexpected error", "path", r.URL.Path, err)
detail = ""
writeProblemStatus(w, r, status, code, "")
return
}
log.Debug(r.Context(), "API v1: request failed", "path", r.URL.Path, "status", status, "code", code, err)
var se *scopeError
if errors.As(err, &se) {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error="insufficient_scope", scope=%q`, se.scope))
}
var detail string
var ce *clientError
if errors.As(err, &ce) {
detail = ce.detail
}
var fe *fieldErrors
if errors.As(err, &fe) {
writeProblemStatus(w, r, status, code, detail, fe.fields...)
return
}
writeProblemStatus(w, r, status, code, detail)
}
func classifyError(err error) (int, ProblemCode) {
switch {
case tooLarge(err):
return http.StatusRequestEntityTooLarge, ProblemCodePayloadTooLarge
case errors.As(err, new(*scopeError)):
return http.StatusForbidden, ProblemCodeInsufficientScope
case errors.Is(err, auth.ErrSetupComplete):
return http.StatusConflict, ProblemCodeSetupComplete
case errors.Is(err, apiauth.ErrPasswordManagedExternally):
return http.StatusConflict, ProblemCodePasswordManagedExternally
case errors.Is(err, model.ErrNotFound):
return http.StatusNotFound, ProblemCodeNotFound
case errors.Is(err, model.ErrNotAuthorized):
@ -45,6 +108,19 @@ func writeProblemStatus(w http.ResponseWriter, r *http.Request, status int, code
if len(fieldErrors) > 0 {
p.Errors = &fieldErrors
}
if status == http.StatusInternalServerError {
if ref := referenceIDFrom(r.Context()); ref != "" {
p.ReferenceId = &ref
}
}
// Every 401 carries a Bearer challenge; callers may set a more specific one first.
if status == http.StatusUnauthorized && w.Header().Get("WWW-Authenticate") == "" {
challenge := "Bearer"
if _, sent := bearerToken(r); sent {
challenge = `Bearer error="invalid_token"`
}
w.Header().Set("WWW-Authenticate", challenge)
}
w.Header().Set("Content-Type", problemContentType)
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(p); err != nil {
@ -53,20 +129,20 @@ func writeProblemStatus(w http.ResponseWriter, r *http.Request, status int, code
}
func bindingErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
var fieldErrors []ValidationError
var fieldErrs []ValidationError
var required *RequiredParamError
var invalid *InvalidParamFormatError
var tooMany *TooManyValuesForParamError
var unmarshal *UnmarshalingParamError
switch {
case errors.As(err, &required):
fieldErrors = append(fieldErrors, ValidationError{Field: required.ParamName, Message: "is required"})
fieldErrs = append(fieldErrs, ValidationError{Field: required.ParamName, Message: "is required"})
case errors.As(err, &invalid):
fieldErrors = append(fieldErrors, ValidationError{Field: invalid.ParamName, Message: invalid.Err.Error()})
fieldErrs = append(fieldErrs, ValidationError{Field: invalid.ParamName, Message: "has an invalid value"})
case errors.As(err, &tooMany):
fieldErrors = append(fieldErrors, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"})
fieldErrs = append(fieldErrs, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"})
case errors.As(err, &unmarshal):
fieldErrors = append(fieldErrors, ValidationError{Field: unmarshal.ParamName, Message: unmarshal.Err.Error()})
fieldErrs = append(fieldErrs, ValidationError{Field: unmarshal.ParamName, Message: "has an invalid value"})
}
writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, err.Error(), fieldErrors...)
writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "invalid request parameters", fieldErrs...)
}

View file

@ -1,12 +1,15 @@
package apiv1
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/core/apiauth"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -19,12 +22,14 @@ func decodeProblem(w *httptest.ResponseRecorder) Problem {
}
var _ = Describe("problem", func() {
var ctx context.Context
var w *httptest.ResponseRecorder
var r *http.Request
BeforeEach(func() {
ctx = GinkgoT().Context()
w = httptest.NewRecorder()
r = httptest.NewRequest(http.MethodGet, "/api/v1/server", nil)
r = httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/server", nil)
})
Describe("writeProblem", func() {
@ -46,20 +51,68 @@ var _ = Describe("problem", func() {
Entry("expired", model.ErrExpired, http.StatusUnauthorized, ProblemCodeUnauthorized),
Entry("validation", model.ErrValidation, http.StatusBadRequest, ProblemCodeValidation),
Entry("not available", model.ErrNotAvailable, http.StatusServiceUnavailable, ProblemCodeUnavailable),
Entry("insufficient scope", &scopeError{scope: "read"}, http.StatusForbidden, ProblemCodeInsufficientScope),
Entry("setup complete", auth.ErrSetupComplete, http.StatusConflict, ProblemCodeSetupComplete),
Entry("password managed externally", apiauth.ErrPasswordManagedExternally, http.StatusConflict, ProblemCodePasswordManagedExternally),
Entry("unknown", errors.New("boom"), http.StatusInternalServerError, ProblemCodeInternal),
)
DescribeTable("keeps the wrapping context as detail for client errors",
func(err error) {
writeProblem(w, r, err)
p := decodeProblem(w)
Expect(p.Status).To(Equal(http.StatusNotFound))
Expect(p.Detail).ToNot(BeNil())
Expect(*p.Detail).To(ContainSubstring("album 123"))
},
Entry("fmt.Errorf %w", fmt.Errorf("album 123: %w", model.ErrNotFound)),
Entry("errors.Join", errors.Join(errors.New("album 123"), model.ErrNotFound)),
)
It("shows detail only for errors marked as client-facing", func() {
writeProblem(w, r, fmt.Errorf("album 123: %w", model.ErrNotFound))
Expect(decodeProblem(w).Detail).To(BeNil())
w = httptest.NewRecorder()
writeProblem(w, r, ClientError(model.ErrNotFound, "album not found"))
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeNotFound))
Expect(*p.Detail).To(Equal("album not found"))
})
It("writes field errors from validationFailed", func() {
writeProblem(w, r, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"}))
p := decodeProblem(w)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"}))
})
It("writes and logs a problem with debug logging on", func() {
logs := captureLogs()
writeProblem(w, r, model.ErrNotFound)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(logs.String()).To(ContainSubstring("code=not_found"))
})
It("adds a Bearer challenge to every 401 unless one is already set", func() {
writeProblem(w, r, model.ErrInvalidAuth)
Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
w = httptest.NewRecorder()
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
writeProblem(w, r, model.ErrInvalidAuth)
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
})
It("challenges a 401 with invalid_token when the request carried a bearer token", func() {
r.Header.Set("Authorization", "Bearer tok")
writeProblem(w, r, model.ErrInvalidAuth)
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
w = httptest.NewRecorder()
r.Header.Set("Authorization", "Basic dXNlcjpwdw==")
writeProblem(w, r, model.ErrInvalidAuth)
Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
})
It("adds the request's referenceId to internal errors only", func() {
r = r.WithContext(withReferenceID(r.Context(), "ref-123"))
writeProblem(w, r, errors.New("boom"))
Expect(*decodeProblem(w).ReferenceId).To(Equal("ref-123"))
w = httptest.NewRecorder()
writeProblem(w, r, model.ErrNotFound)
Expect(decodeProblem(w).ReferenceId).To(BeNil())
})
It("hides details for internal errors", func() {
writeProblem(w, r, errors.New("db password is hunter2"))
@ -95,14 +148,19 @@ var _ = Describe("problem", func() {
Expect(p.Errors).ToNot(BeNil())
Expect(*p.Errors).To(HaveLen(1))
Expect((*p.Errors)[0].Field).To(Equal(field))
Expect((*p.Errors)[0].Message).To(ContainSubstring(message))
Expect((*p.Errors)[0].Message).To(Equal(message))
},
Entry("required", &RequiredParamError{ParamName: "limit"}, "limit", "is required"),
Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New("not a number")}, "offset", "not a number"),
Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "single value"),
Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "bad json"),
Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New(`parsing "abc": invalid syntax`)}, "offset", "has an invalid value"),
Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "expected a single value"),
Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "has an invalid value"),
)
It("never echoes the submitted value", func() {
bindingErrorHandler(w, r, &InvalidParamFormatError{ParamName: "offset", Err: errors.New(`parsing "hunter2": invalid syntax`)})
Expect(w.Body.String()).ToNot(ContainSubstring("hunter2"))
})
It("still returns a validation problem for unknown binding errors", func() {
bindingErrorHandler(w, r, errors.New("weird"))
p := decodeProblem(w)

29
server/apiv1/reference.go Normal file
View file

@ -0,0 +1,29 @@
package apiv1
import (
"context"
"net/http"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/id"
)
type referenceIDKey struct{}
func withReferenceID(ctx context.Context, ref string) context.Context {
return context.WithValue(ctx, referenceIDKey{}, ref)
}
func referenceIDFrom(ctx context.Context) string {
ref, _ := ctx.Value(referenceIDKey{}).(string)
return ref
}
// referenceIDMiddleware tags every log line of the request with an id that 500 problems also carry.
func referenceIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ref := id.NewRandom()
ctx := log.NewContext(withReferenceID(r.Context(), ref), "referenceId", ref)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View file

@ -18,6 +18,13 @@ func (rt *Router) GetServerInfo(ctx context.Context, _ GetServerInfoRequestObjec
ServerVersion: consts.Version,
SpecVersion: api.SpecVersion(),
SetupRequired: count == 0,
LoginMethods: []ServerInfoLoginMethods{ServerInfoLoginMethodsPassword},
LoginMethods: LoginMethods{Password: &PasswordLoginMethod{}},
}, nil
}
func (rt *Router) GetCapabilities(context.Context, GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) {
return GetCapabilities200JSONResponse{
Core: &CoreCapability{Version: 1},
Password: &PasswordCapability{Version: 1},
}, nil
}

View file

@ -8,6 +8,7 @@ import (
"net/http/httptest"
"github.com/navidrome/navidrome/api"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@ -43,7 +44,8 @@ var _ = Describe("GET /server", func() {
Expect(info.ServerVersion).To(Equal(consts.Version))
Expect(info.SpecVersion).To(Equal(api.SpecVersion()))
Expect(info.SetupRequired).To(BeTrue())
Expect(info.LoginMethods).To(ConsistOf(ServerInfoLoginMethodsPassword))
Expect(info.LoginMethods.Password).ToNot(BeNil())
Expect(w.Body.String()).To(ContainSubstring(`"loginMethods":{"password":{}}`))
})
It("reports setupRequired=false once a user exists", func() {
@ -59,3 +61,33 @@ var _ = Describe("GET /server", func() {
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInternal))
})
})
var _ = Describe("GET /capabilities", func() {
var ctx context.Context
var api testClient
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
resetDB()
api = testClient{ctx: ctx, router: New(realDS)}
})
It("needs a grant", func() {
w := api.call(http.MethodGet, "/api/v1/capabilities", "", nil)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("lists core and password for any valid grant, even one with no scopes", func() {
api.setup()
gc := api.login([]string{})
Expect(gc.Grant.Scopes).To(BeEmpty())
w := api.call(http.MethodGet, "/api/v1/capabilities", gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusOK))
var caps Capabilities
decodeJSON(w, &caps)
Expect(caps.Core.Version).To(Equal(1))
Expect(caps.Password.Version).To(Equal(1))
})
})

View file

@ -13,7 +13,6 @@ import (
"slices"
"strings"
"sync"
"time"
"github.com/deluan/rest"
"github.com/go-chi/jwtauth/v5"
@ -26,8 +25,6 @@ import (
"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 (
@ -127,16 +124,11 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
return
}
c, err := ds.User().CountAll(r.Context())
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
if c > 0 {
_, err = auth.CreateFirstAdmin(r.Context(), ds, username, password, nil)
if errors.Is(err, auth.ErrSetupComplete) {
_ = 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
@ -145,26 +137,6 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request
}
}
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().Put(ctx, &initialUser)
if err != nil {
log.Error(ctx, "Could not create initial user", "user", initialUser.UserName, err)
return fmt.Errorf("creating initial user: %w", err)
}
return nil
}
func validateLogin(ctx context.Context, userRepo model.UserRepository, userName, password string) (*model.User, error) {
u, err := userRepo.FindByUsernameWithPassword(ctx, userName)
if errors.Is(err, model.ErrNotFound) {

View file

@ -74,14 +74,27 @@ var _ = Describe("Auth", func() {
})
})
Describe("createAdminUser", func() {
Describe("CreateFirstAdmin", func() {
It("returns the error when the user cannot be saved", func() {
ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: errors.New("db is down")}}
err := createAdminUser(context.Background(), ds, "johndoe", "secret")
failing := dsWithFailingPut(errors.New("db is down"))
_, err := auth.CreateFirstAdmin(ctx, failing, "johndoe", "secret", nil)
Expect(err).To(MatchError(ContainSubstring("db is down")))
})
})
Describe("createAdmin when a user already exists", func() {
It("responds 403", func() {
req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"another", "password":"secret"}`))
resp = httptest.NewRecorder()
Expect(ds.User().Put(ctx, &model.User{UserName: "johndoe", NewPassword: "secret"})).To(Succeed())
createAdmin(ds)(resp, req)
Expect(resp.Code).To(Equal(http.StatusForbidden))
Expect(resp.Body.String()).To(ContainSubstring("Cannot create another first admin"))
})
})
Describe("createAdmin when the user cannot be stored", func() {
It("responds 500 rather than falling through to login", func() {
failing := dsWithFailingPut(errors.New("db is down"))

View file

@ -234,16 +234,21 @@ func trustedProxyPrefixes(list string) []string {
// ClientIPRateLimiter returns a rate limiter keyed by ClientIP, so spoofed forwarding headers
// cannot be rotated for a fresh bucket.
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler {
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration, opts ...httprate.Option) func(http.Handler) http.Handler {
return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
return ClientIP(r), nil
})
}, opts...)
}
// ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The
// peer address fallback degrades a missing middleware to per-peer limiting, not one shared bucket.
func ClientIP(r *http.Request) string {
return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r)))
return httprate.CanonicalizeIP(ClientAddr(r))
}
// ClientAddr returns the client IP resolved by realIPMiddleware unmasked, for recording who made a request.
func ClientAddr(r *http.Request) string {
return cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))
}
// reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key

View file

@ -494,6 +494,35 @@ var _ = Describe("middlewares", func() {
})
})
Describe("ClientAddr", func() {
var ctx context.Context
var addr, ip string
BeforeEach(func() {
ctx = GinkgoT().Context()
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"
})
call := func(h http.Handler, peer, xff string) {
r := httptest.NewRequestWithContext(ctx, "POST", "/auth/login", nil)
r.RemoteAddr = peer
r.Header.Set("X-Forwarded-For", xff)
h.ServeHTTP(httptest.NewRecorder(), r)
}
capture := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
addr, ip = ClientAddr(r), ClientIP(r)
})
It("returns the full resolved IPv6 address, while ClientIP keeps the /64 for rate limiting", func() {
call(realIPMiddleware(capture), "10.0.0.1:1234", "2001:db8:1:2:3:4:5:6")
Expect(addr).To(Equal("2001:db8:1:2:3:4:5:6"))
Expect(ip).To(Equal("2001:db8:1:2::"))
})
It("falls back to the peer host without the middleware", func() {
call(capture, "[2001:db8:1:2:3:4:5:6]:1234", "")
Expect(addr).To(Equal("2001:db8:1:2:3:4:5:6"))
})
})
Describe("ClientIPRateLimiter", func() {
var handler http.Handler
JustBeforeEach(func() {
@ -520,6 +549,12 @@ var _ = Describe("middlewares", func() {
Entry("True-Client-IP", "True-Client-IP"),
)
It("sends the X-RateLimit headers by default", func() {
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequestWithContext(GinkgoT().Context(), "POST", "/auth/login", nil))
Expect(w.Header().Get("X-RateLimit-Limit")).To(Equal("2"))
})
Context("behind a trusted proxy", func() {
BeforeEach(func() {
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"

View file

@ -30,6 +30,7 @@ type MockDataStore struct {
MockedPlugin model.PluginRepository
MockedArtwork model.ArtworkRepository
MockedArtworkQueue model.ArtworkQueueRepository
MockedGrant model.GrantRepository
scrobbleBufferMu sync.Mutex
repoMu sync.Mutex
@ -321,6 +322,19 @@ func (db *MockDataStore) ArtworkQueue() model.ArtworkQueueRepository {
return db.MockedArtworkQueue
}
func (db *MockDataStore) Grant() model.GrantRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedGrant != nil {
return db.MockedGrant
}
if db.RealDS != nil {
return db.RealDS.Grant()
}
db.MockedGrant = &MockedGrantRepo{}
return db.MockedGrant
}
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
return block(db)
}

8
tests/mock_grant_repo.go Normal file
View file

@ -0,0 +1,8 @@
package tests
import "github.com/navidrome/navidrome/model"
// MockedGrantRepo exists so MockDataStore satisfies DataStore; auth tests use a real database.
type MockedGrantRepo struct {
model.GrantRepository
}