diff --git a/api/.vacuum.yaml b/api/.vacuum.yaml index bd31e23f8..261502224 100644 --- a/api/.vacuum.yaml +++ b/api/.vacuum.yaml @@ -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 diff --git a/api/bundled/openapi.json b/api/bundled/openapi.json index bfe794c71..5755ee75b 100644 --- a/api/bundled/openapi.json +++ b/api/bundled/openapi.json @@ -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" + ] + } } } } diff --git a/api/bundled/openapi.yaml b/api/bundled/openapi.yaml index f1ae95b76..8b430b629 100644 --- a/api/bundled/openapi.yaml +++ b/api/bundled/openapi.yaml @@ -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 `. 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 `." + 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 diff --git a/api/openapi/components/headers/CacheControlNoStore.yaml b/api/openapi/components/headers/CacheControlNoStore.yaml new file mode 100644 index 000000000..07250936b --- /dev/null +++ b/api/openapi/components/headers/CacheControlNoStore.yaml @@ -0,0 +1,4 @@ +description: Always `no-store`, because the response carries a secret. +schema: + type: string + enum: [no-store] diff --git a/api/openapi/components/headers/WWWAuthenticate.yaml b/api/openapi/components/headers/WWWAuthenticate.yaml new file mode 100644 index 000000000..65d5fb2fb --- /dev/null +++ b/api/openapi/components/headers/WWWAuthenticate.yaml @@ -0,0 +1,3 @@ +description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.' +schema: + type: string diff --git a/api/openapi/components/responses/Conflict.yaml b/api/openapi/components/responses/Conflict.yaml new file mode 100644 index 000000000..a602ffd6b --- /dev/null +++ b/api/openapi/components/responses/Conflict.yaml @@ -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 diff --git a/api/openapi/components/responses/Forbidden.yaml b/api/openapi/components/responses/Forbidden.yaml index 6259185ea..9f39b6b53 100644 --- a/api/openapi/components/responses/Forbidden.yaml +++ b/api/openapi/components/responses/Forbidden.yaml @@ -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: diff --git a/api/openapi/components/responses/PayloadTooLarge.yaml b/api/openapi/components/responses/PayloadTooLarge.yaml new file mode 100644 index 000000000..4918bf1e7 --- /dev/null +++ b/api/openapi/components/responses/PayloadTooLarge.yaml @@ -0,0 +1,5 @@ +description: "The request body is too large (`payload_too_large`)." +content: + application/problem+json: + schema: + $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/TooManyRequests.yaml b/api/openapi/components/responses/TooManyRequests.yaml new file mode 100644 index 000000000..de82cd59e --- /dev/null +++ b/api/openapi/components/responses/TooManyRequests.yaml @@ -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 diff --git a/api/openapi/components/responses/Unauthorized.yaml b/api/openapi/components/responses/Unauthorized.yaml index 0209f4dd9..87d5bab08 100644 --- a/api/openapi/components/responses/Unauthorized.yaml +++ b/api/openapi/components/responses/Unauthorized.yaml @@ -1,4 +1,7 @@ description: Missing, invalid, or expired credentials. +headers: + WWW-Authenticate: + $ref: ../headers/WWWAuthenticate.yaml content: application/problem+json: schema: diff --git a/api/openapi/components/schemas/AuthUser.yaml b/api/openapi/components/schemas/AuthUser.yaml new file mode 100644 index 000000000..722a6823c --- /dev/null +++ b/api/openapi/components/schemas/AuthUser.yaml @@ -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." diff --git a/api/openapi/components/schemas/Capabilities.yaml b/api/openapi/components/schemas/Capabilities.yaml new file mode 100644 index 000000000..aa760e8e5 --- /dev/null +++ b/api/openapi/components/schemas/Capabilities.yaml @@ -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 diff --git a/api/openapi/components/schemas/CoreCapability.yaml b/api/openapi/components/schemas/CoreCapability.yaml new file mode 100644 index 000000000..c52c21318 --- /dev/null +++ b/api/openapi/components/schemas/CoreCapability.yaml @@ -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.} diff --git a/api/openapi/components/schemas/CredentialsRequest.yaml b/api/openapi/components/schemas/CredentialsRequest.yaml new file mode 100644 index 000000000..08e198b23 --- /dev/null +++ b/api/openapi/components/schemas/CredentialsRequest.yaml @@ -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 diff --git a/api/openapi/components/schemas/Grant.yaml b/api/openapi/components/schemas/Grant.yaml new file mode 100644 index 000000000..95b4aae19 --- /dev/null +++ b/api/openapi/components/schemas/Grant.yaml @@ -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. diff --git a/api/openapi/components/schemas/GrantCreated.yaml b/api/openapi/components/schemas/GrantCreated.yaml new file mode 100644 index 000000000..d385ba335 --- /dev/null +++ b/api/openapi/components/schemas/GrantCreated.yaml @@ -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 `." + grant: + description: The new grant. + allOf: + - $ref: ./Grant.yaml + user: + description: The user the grant belongs to. + allOf: + - $ref: ./AuthUser.yaml diff --git a/api/openapi/components/schemas/GrantList.yaml b/api/openapi/components/schemas/GrantList.yaml new file mode 100644 index 000000000..8e80391ae --- /dev/null +++ b/api/openapi/components/schemas/GrantList.yaml @@ -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. diff --git a/api/openapi/components/schemas/LoginMethods.yaml b/api/openapi/components/schemas/LoginMethods.yaml new file mode 100644 index 000000000..106d3a6ed --- /dev/null +++ b/api/openapi/components/schemas/LoginMethods.yaml @@ -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 diff --git a/api/openapi/components/schemas/LogoutResponse.yaml b/api/openapi/components/schemas/LogoutResponse.yaml new file mode 100644 index 000000000..12700fba7 --- /dev/null +++ b/api/openapi/components/schemas/LogoutResponse.yaml @@ -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." diff --git a/api/openapi/components/schemas/PasswordCapability.yaml b/api/openapi/components/schemas/PasswordCapability.yaml new file mode 100644 index 000000000..319cdbf3d --- /dev/null +++ b/api/openapi/components/schemas/PasswordCapability.yaml @@ -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.} diff --git a/api/openapi/components/schemas/PasswordChangeRequest.yaml b/api/openapi/components/schemas/PasswordChangeRequest.yaml new file mode 100644 index 000000000..c176014c4 --- /dev/null +++ b/api/openapi/components/schemas/PasswordChangeRequest.yaml @@ -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." diff --git a/api/openapi/components/schemas/PasswordLoginMethod.yaml b/api/openapi/components/schemas/PasswordLoginMethod.yaml new file mode 100644 index 000000000..e8a1f0ee2 --- /dev/null +++ b/api/openapi/components/schemas/PasswordLoginMethod.yaml @@ -0,0 +1,2 @@ +type: object +description: Username and password login (`POST /auth/login`). No settings yet. diff --git a/api/openapi/components/schemas/Problem.yaml b/api/openapi/components/schemas/Problem.yaml index 0fd36d4b1..fb966f914 100644 --- a/api/openapi/components/schemas/Problem.yaml +++ b/api/openapi/components/schemas/Problem.yaml @@ -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`. diff --git a/api/openapi/components/schemas/Scope.yaml b/api/openapi/components/schemas/Scope.yaml new file mode 100644 index 000000000..a1763945f --- /dev/null +++ b/api/openapi/components/schemas/Scope.yaml @@ -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] diff --git a/api/openapi/components/schemas/ScopeRequest.yaml b/api/openapi/components/schemas/ScopeRequest.yaml new file mode 100644 index 000000000..a9a936147 --- /dev/null +++ b/api/openapi/components/schemas/ScopeRequest.yaml @@ -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 diff --git a/api/openapi/components/schemas/ServerInfo.yaml b/api/openapi/components/schemas/ServerInfo.yaml index 8906924eb..f80fbce5c 100644 --- a/api/openapi/components/schemas/ServerInfo.yaml +++ b/api/openapi/components/schemas/ServerInfo.yaml @@ -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 diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 73cbb7b27..02a0bda8d 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -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 `. 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`." diff --git a/api/openapi/paths/auth.yaml b/api/openapi/paths/auth.yaml new file mode 100644 index 000000000..83c28ea15 --- /dev/null +++ b/api/openapi/paths/auth.yaml @@ -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 diff --git a/api/openapi/paths/capabilities.yaml b/api/openapi/paths/capabilities.yaml new file mode 100644 index 000000000..dc0d9d7ca --- /dev/null +++ b/api/openapi/paths/capabilities.yaml @@ -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} diff --git a/api/openapi/paths/openapi.yaml b/api/openapi/paths/openapi.yaml index 3c25dc8b7..bc0b874f6 100644 --- a/api/openapi/paths/openapi.yaml +++ b/api/openapi/paths/openapi.yaml @@ -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: diff --git a/api/openapi/paths/server.yaml b/api/openapi/paths/server.yaml index 1f881dbb1..1e17a3b87 100644 --- a/api/openapi/paths/server.yaml +++ b/api/openapi/paths/server.yaml @@ -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. diff --git a/consts/consts.go b/consts/consts.go index 42e9ec42f..8077737a8 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -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 diff --git a/core/agents/session_keys.go b/core/agents/session_keys.go index 1eb414b15..400c54fc7 100644 --- a/core/agents/session_keys.go +++ b/core/agents/session_keys.go @@ -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) } diff --git a/core/agents/session_keys_test.go b/core/agents/session_keys_test.go index e0232c08e..66eaf3a57 100644 --- a/core/agents/session_keys_test.go +++ b/core/agents/session_keys_test.go @@ -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")) + }) }) diff --git a/core/apiauth/apiauth_suite_test.go b/core/apiauth/apiauth_suite_test.go new file mode 100644 index 000000000..5d6143b0e --- /dev/null +++ b/core/apiauth/apiauth_suite_test.go @@ -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") +} diff --git a/core/apiauth/context.go b/core/apiauth/context.go new file mode 100644 index 000000000..082d92d45 --- /dev/null +++ b/core/apiauth/context.go @@ -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 +} diff --git a/core/apiauth/credentials.go b/core/apiauth/credentials.go new file mode 100644 index 000000000..43834ab51 --- /dev/null +++ b/core/apiauth/credentials.go @@ -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 +} diff --git a/core/apiauth/credentials_test.go b/core/apiauth/credentials_test.go new file mode 100644 index 000000000..a57d54c29 --- /dev/null +++ b/core/apiauth/credentials_test.go @@ -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)) + }) +}) diff --git a/core/apiauth/db_test.go b/core/apiauth/db_test.go new file mode 100644 index 000000000..5f54e6007 --- /dev/null +++ b/core/apiauth/db_test.go @@ -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 +} diff --git a/core/apiauth/export_test.go b/core/apiauth/export_test.go new file mode 100644 index 000000000..fdfde677c --- /dev/null +++ b/core/apiauth/export_test.go @@ -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 } diff --git a/core/apiauth/scopes.go b/core/apiauth/scopes.go new file mode 100644 index 000000000..319269a00 --- /dev/null +++ b/core/apiauth/scopes.go @@ -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")) +} diff --git a/core/apiauth/scopes_test.go b/core/apiauth/scopes_test.go new file mode 100644 index 000000000..351f935e2 --- /dev/null +++ b/core/apiauth/scopes_test.go @@ -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()) + }) + }) +}) diff --git a/core/apiauth/secret.go b/core/apiauth/secret.go new file mode 100644 index 000000000..beaed16c5 --- /dev/null +++ b/core/apiauth/secret.go @@ -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[:]) +} diff --git a/core/apiauth/secret_test.go b/core/apiauth/secret_test.go new file mode 100644 index 000000000..3f9bae3da --- /dev/null +++ b/core/apiauth/secret_test.go @@ -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()) + }) +}) diff --git a/core/apiauth/service.go b/core/apiauth/service.go new file mode 100644 index 000000000..3ec70476f --- /dev/null +++ b/core/apiauth/service.go @@ -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) + }) +} diff --git a/core/apiauth/service_test.go b/core/apiauth/service_test.go new file mode 100644 index 000000000..6482122c7 --- /dev/null +++ b/core/apiauth/service_test.go @@ -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") +} diff --git a/core/auth/auth.go b/core/auth/auth.go index 1bdc917da..1d521451a 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -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, diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index c86dcd08c..05da7ec65 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -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") diff --git a/core/auth/first_admin.go b/core/auth/first_admin.go new file mode 100644 index 000000000..a082e6222 --- /dev/null +++ b/core/auth/first_admin.go @@ -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 +} diff --git a/core/auth/first_admin_test.go b/core/auth/first_admin_test.go new file mode 100644 index 000000000..0cbbcf575 --- /dev/null +++ b/core/auth/first_admin_test.go @@ -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 +} diff --git a/db/migrations/20260926045200_create_api_grant.sql b/db/migrations/20260926045200_create_api_grant.sql new file mode 100644 index 000000000..84fec2622 --- /dev/null +++ b/db/migrations/20260926045200_create_api_grant.sql @@ -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 diff --git a/go.mod b/go.mod index e96b8c8b3..12cc95e73 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index fe6dbbc3f..ed9f32f13 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/log/log.go b/log/log.go index da1d7622e..eb6f81cb8 100644 --- a/log/log.go +++ b/log/log.go @@ -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:] } } diff --git a/log/log_test.go b/log/log_test.go index 184ff57db..0e6628adb 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -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() { diff --git a/log/redactrus.go b/log/redactrus.go index 6e17243e7..b8ad0ddb4 100755 --- a/log/redactrus.go +++ b/log/redactrus.go @@ -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 { diff --git a/log/redactrus_test.go b/log/redactrus_test.go index 36a19e2f5..6b9d71f89 100755 --- a/log/redactrus_test.go +++ b/log/redactrus_test.go @@ -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) +} diff --git a/model/datastore.go b/model/datastore.go index 6ded8c575..1175d212d 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -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 diff --git a/model/grant.go b/model/grant.go new file mode 100644 index 000000000..ecbf30e41 --- /dev/null +++ b/model/grant.go @@ -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) +} diff --git a/model/grant_test.go b/model/grant_test.go new file mode 100644 index 000000000..dd59a2422 --- /dev/null +++ b/model/grant_test.go @@ -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)) + }) + }) +}) diff --git a/persistence/grant_repository.go b/persistence/grant_repository.go new file mode 100644 index 000000000..349099983 --- /dev/null +++ b/persistence/grant_repository.go @@ -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()))) +} diff --git a/persistence/grant_repository_test.go b/persistence/grant_repository_test.go new file mode 100644 index 000000000..2436f8bb4 --- /dev/null +++ b/persistence/grant_repository_test.go @@ -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)) + }) +}) diff --git a/persistence/persistence.go b/persistence/persistence.go index 44e944bff..9656be178 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -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) diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index ee2794454..dc6134793 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -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) } diff --git a/persistence/property_repository_test.go b/persistence/property_repository_test.go index 880b315ec..eac174c5b 100644 --- a/persistence/property_repository_test.go +++ b/persistence/property_repository_test.go @@ -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")) + }) }) diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 20b4e5125..26f7b19d4 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -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 { diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 0e776fc3a..156d43253 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -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 diff --git a/server/apiv1/api.go b/server/apiv1/api.go index a75de3d8d..6acd95cfe 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -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, "/") } diff --git a/server/apiv1/api_gen.go b/server/apiv1/api_gen.go index 4bffd47fc..9afd22465 100644 --- a/server/apiv1/api_gen.go +++ b/server/apiv1/api_gen.go @@ -7,21 +7,29 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" + "time" "github.com/go-chi/chi/v5" + "github.com/oapi-codegen/runtime" ) // Defines values for ProblemCode. const ( - ProblemCodeForbidden ProblemCode = "forbidden" - ProblemCodeInternal ProblemCode = "internal" - ProblemCodeMethodNotAllowed ProblemCode = "method_not_allowed" - ProblemCodeNotFound ProblemCode = "not_found" - ProblemCodeUnauthorized ProblemCode = "unauthorized" - ProblemCodeUnavailable ProblemCode = "unavailable" - ProblemCodeValidation ProblemCode = "validation" + ProblemCodeForbidden ProblemCode = "forbidden" + ProblemCodeInsufficientScope ProblemCode = "insufficient_scope" + ProblemCodeInternal ProblemCode = "internal" + ProblemCodeMethodNotAllowed ProblemCode = "method_not_allowed" + ProblemCodeNotFound ProblemCode = "not_found" + ProblemCodePasswordManagedExternally ProblemCode = "password_managed_externally" + ProblemCodePayloadTooLarge ProblemCode = "payload_too_large" + ProblemCodeRateLimited ProblemCode = "rate_limited" + ProblemCodeSetupComplete ProblemCode = "setup_complete" + ProblemCodeUnauthorized ProblemCode = "unauthorized" + ProblemCodeUnavailable ProblemCode = "unavailable" + ProblemCodeValidation ProblemCode = "validation" ) // Valid indicates whether the value is a known member of the ProblemCode enum. @@ -29,12 +37,22 @@ func (e ProblemCode) Valid() bool { switch e { case ProblemCodeForbidden: return true + case ProblemCodeInsufficientScope: + return true case ProblemCodeInternal: return true case ProblemCodeMethodNotAllowed: return true case ProblemCodeNotFound: return true + case ProblemCodePasswordManagedExternally: + return true + case ProblemCodePayloadTooLarge: + return true + case ProblemCodeRateLimited: + return true + case ProblemCodeSetupComplete: + return true case ProblemCodeUnauthorized: return true case ProblemCodeUnavailable: @@ -46,32 +64,192 @@ func (e ProblemCode) Valid() bool { } } -// Defines values for ServerInfoLoginMethods. +// Defines values for Scope. const ( - ServerInfoLoginMethodsPassword ServerInfoLoginMethods = "password" + ScopeAll Scope = "all" + ScopePassword Scope = "password" + ScopeRead Scope = "read" ) -// Valid indicates whether the value is a known member of the ServerInfoLoginMethods enum. -func (e ServerInfoLoginMethods) Valid() bool { +// Valid indicates whether the value is a known member of the Scope enum. +func (e Scope) Valid() bool { switch e { - case ServerInfoLoginMethodsPassword: + case ScopeAll: + return true + case ScopePassword: + return true + case ScopeRead: return true default: return false } } +// AuthUser The user a grant belongs to. +type AuthUser struct { + // Id User id. + Id string `json:"id"` + + // IsAdmin Whether the user is an administrator. + IsAdmin bool `json:"isAdmin"` + + // Name Display name. + Name string `json:"name"` + + // PasswordChangeable Whether `POST /auth/password` can change this user's password. Clients hide "change password" when false. + PasswordChangeable bool `json:"passwordChangeable"` + + // UserName Login name. + UserName string `json:"userName"` +} + +// Capabilities 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. +type Capabilities struct { + // Core The mandatory core module. + Core *CoreCapability `json:"core,omitempty"` + + // Password The password login module (login, first-admin setup, password change). + Password *PasswordCapability `json:"password,omitempty"` +} + +// CoreCapability The mandatory core module. +type CoreCapability struct { + // Version Module version. Bumped only on semantic change. + Version int `json:"version"` +} + +// CredentialsRequest Username, password and client description for a login or first-admin setup. +type CredentialsRequest struct { + // Client Name of the client app. + Client string `json:"client"` + + // ClientVersion Version of the client app. + ClientVersion *string `json:"clientVersion,omitempty"` + + // Name Label for this grant. Defaults to `client`. + Name *string `json:"name,omitempty"` + + // Password Password. + Password string `json:"password"` + + // Scopes Scopes the grant may hold. Omit for `all`. + Scopes *[]ScopeRequest `json:"scopes,omitempty"` + + // Username Login name. + Username string `json:"username"` +} + +// Grant A long-lived grant held by one client of one user. +type Grant struct { + // Client Name of the client app that holds the grant. + Client string `json:"client"` + + // ClientVersion Version of the client app, when it sent one. + ClientVersion *string `json:"clientVersion"` + + // CreatedAt When the grant was created. + CreatedAt time.Time `json:"createdAt"` + + // Current True for the grant that made this request. + Current bool `json:"current"` + + // Id Grant id. + Id string `json:"id"` + + // LastUsedAt When the grant was last used, at a coarse granularity. Null until first use. + LastUsedAt *time.Time `json:"lastUsedAt"` + + // LastUsedIp Client IP of the last use. Null until first use. + LastUsedIp *string `json:"lastUsedIp"` + + // Name Label shown to the user. + Name string `json:"name"` + + // Provider How the grant was created, for example `password` or `setup`. Free-form; new values may appear. + Provider string `json:"provider"` + + // Scopes Scopes this grant carries. + Scopes []Scope `json:"scopes"` +} + +// GrantCreated Returned by every login method. The secret is shown only here; store it and never parse it. +type GrantCreated struct { + // Grant The new grant. + Grant Grant `json:"grant"` + + // Secret Opaque grant secret. Send it as `Authorization: Bearer `. + Secret string `json:"secret"` + + // User The user the grant belongs to. + User AuthUser `json:"user"` +} + +// GrantList A page of the caller's grants. +type GrantList struct { + // Items Grants on this page, by last use, most recent first; never-used grants last. + Items []Grant `json:"items"` + + // Limit Maximum number of items in this page. + Limit int `json:"limit"` + + // Offset Zero-based index of the first returned item. + Offset int `json:"offset"` + + // Total Total number of grants. + Total int `json:"total"` +} + +// LoginMethods 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. +type LoginMethods struct { + // Password Username and password login (`POST /auth/login`). No settings yet. + Password *PasswordLoginMethod `json:"password,omitempty"` +} + +// LogoutResponse Result of a logout. +type LogoutResponse struct { + // LogoutUrl Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do. + LogoutUrl *string `json:"logoutUrl"` +} + +// PasswordCapability The password login module (login, first-admin setup, password change). +type PasswordCapability struct { + // Version Module version. Bumped only on semantic change. + Version int `json:"version"` +} + +// PasswordChangeRequest Change the caller's own password. +type PasswordChangeRequest struct { + // CurrentPassword The current password. + CurrentPassword string `json:"currentPassword"` + + // NewPassword The new password. + NewPassword string `json:"newPassword"` + + // RevokeOtherGrants Revoke every other grant of the user. The calling grant always survives. Default true. + RevokeOtherGrants *bool `json:"revokeOtherGrants,omitempty"` +} + +// PasswordLoginMethod Username and password login (`POST /auth/login`). No settings yet. +type PasswordLoginMethod = map[string]interface{} + // Problem RFC 9457 problem details, returned for every 4xx and 5xx response. type Problem struct { // Code Machine-readable error code, and the value clients switch on. New codes may be added. Code ProblemCode `json:"code"` - // Detail Human-readable explanation specific to this occurrence. Omitted for internal errors. + // Detail Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients. Detail *string `json:"detail,omitempty"` // Errors Per-field failures. Present only when `code` is `validation`. Errors *[]ValidationError `json:"errors,omitempty"` + // ReferenceId Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request. + ReferenceId *string `json:"referenceId,omitempty"` + // Status HTTP status code of this response. Status int `json:"status"` @@ -87,10 +265,19 @@ type Problem struct { // ProblemCode Machine-readable error code, and the value clients switch on. New codes may be added. type ProblemCode string +// Scope 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. +type Scope string + +// ScopeRequest A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working. +type ScopeRequest = string + // ServerInfo Public server description. Everything an add-server screen needs before login. type ServerInfo struct { - // LoginMethods Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. - LoginMethods []ServerInfoLoginMethods `json:"loginMethods"` + // LoginMethods 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. + LoginMethods LoginMethods `json:"loginMethods"` // Name Human-readable server product name. Name string `json:"name"` @@ -105,9 +292,6 @@ type ServerInfo struct { SpecVersion string `json:"specVersion"` } -// ServerInfoLoginMethods defines model for ServerInfo.LoginMethods. -type ServerInfoLoginMethods string - // ValidationError One field-level validation failure. type ValidationError struct { // Field Name of the offending query parameter, path parameter, or body field (dotted for nested). @@ -117,11 +301,77 @@ type ValidationError struct { Message string `json:"message"` } +// LimitParam defines model for limit. +type LimitParam = int + +// OffsetParam defines model for offset. +type OffsetParam = int + +// BadRequest RFC 9457 problem details, returned for every 4xx and 5xx response. +type BadRequest = Problem + +// Conflict RFC 9457 problem details, returned for every 4xx and 5xx response. +type Conflict = Problem + +// Forbidden RFC 9457 problem details, returned for every 4xx and 5xx response. +type Forbidden = Problem + // InternalError RFC 9457 problem details, returned for every 4xx and 5xx response. type InternalError = Problem +// NotFound RFC 9457 problem details, returned for every 4xx and 5xx response. +type NotFound = Problem + +// PayloadTooLarge RFC 9457 problem details, returned for every 4xx and 5xx response. +type PayloadTooLarge = Problem + +// TooManyRequests RFC 9457 problem details, returned for every 4xx and 5xx response. +type TooManyRequests = Problem + +// Unauthorized RFC 9457 problem details, returned for every 4xx and 5xx response. +type Unauthorized = Problem + +// ListGrantsParams defines parameters for ListGrants. +type ListGrantsParams struct { + // OffsetParam Zero-based index of the first item to return. + OffsetParam *OffsetParam `form:"offset,omitempty" json:"offset,omitempty"` + + // LimitParam Maximum number of items to return. + LimitParam *LimitParam `form:"limit,omitempty" json:"limit,omitempty"` +} + +// LoginJSONRequestBody defines body for Login for application/json ContentType. +type LoginJSONRequestBody = CredentialsRequest + +// ChangePasswordJSONRequestBody defines body for ChangePassword for application/json ContentType. +type ChangePasswordJSONRequestBody = PasswordChangeRequest + +// SetupFirstAdminJSONRequestBody defines body for SetupFirstAdmin for application/json ContentType. +type SetupFirstAdminJSONRequestBody = CredentialsRequest + // ServerInterface represents all server handlers. type ServerInterface interface { + // ListGrants List my grants + // (GET /auth/grants) + ListGrants(w http.ResponseWriter, r *http.Request, params ListGrantsParams) + // RevokeGrant Revoke one of my grants + // (DELETE /auth/grants/{id}) + RevokeGrant(w http.ResponseWriter, r *http.Request, id string) + // Login Log in with a password + // (POST /auth/login) + Login(w http.ResponseWriter, r *http.Request) + // Logout Log out + // (POST /auth/logout) + Logout(w http.ResponseWriter, r *http.Request) + // ChangePassword Change my password + // (POST /auth/password) + ChangePassword(w http.ResponseWriter, r *http.Request) + // SetupFirstAdmin Create the first admin + // (POST /auth/setup) + SetupFirstAdmin(w http.ResponseWriter, r *http.Request) + // GetCapabilities List implemented capability modules + // (GET /capabilities) + GetCapabilities(w http.ResponseWriter, r *http.Request) // GetServerInfo Describe the server // (GET /server) GetServerInfo(w http.ResponseWriter, r *http.Request) @@ -131,6 +381,48 @@ type ServerInterface interface { type Unimplemented struct{} +// ListGrants List my grants +// (GET /auth/grants) +func (_ Unimplemented) ListGrants(w http.ResponseWriter, r *http.Request, params ListGrantsParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// RevokeGrant Revoke one of my grants +// (DELETE /auth/grants/{id}) +func (_ Unimplemented) RevokeGrant(w http.ResponseWriter, r *http.Request, id string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Login Log in with a password +// (POST /auth/login) +func (_ Unimplemented) Login(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Logout Log out +// (POST /auth/logout) +func (_ Unimplemented) Logout(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// ChangePassword Change my password +// (POST /auth/password) +func (_ Unimplemented) ChangePassword(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// SetupFirstAdmin Create the first admin +// (POST /auth/setup) +func (_ Unimplemented) SetupFirstAdmin(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// GetCapabilities List implemented capability modules +// (GET /capabilities) +func (_ Unimplemented) GetCapabilities(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // GetServerInfo Describe the server // (GET /server) func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) { @@ -146,6 +438,148 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(http.Handler) http.Handler +// ListGrants operation middleware +func (siw *ServerInterfaceWrapper) ListGrants(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params ListGrantsParams + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "offset", r.URL.Query(), ¶ms.OffsetParam, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "offset"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "offset", Err: err}) + } + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.LimitParam, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListGrants(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RevokeGrant operation middleware +func (siw *ServerInterfaceWrapper) RevokeGrant(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: r.URL.RawPath == ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RevokeGrant(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// Login operation middleware +func (siw *ServerInterfaceWrapper) Login(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.Login(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// Logout operation middleware +func (siw *ServerInterfaceWrapper) Logout(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.Logout(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ChangePassword operation middleware +func (siw *ServerInterfaceWrapper) ChangePassword(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ChangePassword(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// SetupFirstAdmin operation middleware +func (siw *ServerInterfaceWrapper) SetupFirstAdmin(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SetupFirstAdmin(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetCapabilities operation middleware +func (siw *ServerInterfaceWrapper) GetCapabilities(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetCapabilities(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetServerInfo operation middleware func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.Request) { @@ -273,6 +707,27 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl ErrorHandlerFunc: options.ErrorHandlerFunc, } + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/auth/grants", wrapper.ListGrants) + }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/auth/grants/{id}", wrapper.RevokeGrant) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/auth/login", wrapper.Login) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/auth/logout", wrapper.Logout) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/auth/password", wrapper.ChangePassword) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/auth/setup", wrapper.SetupFirstAdmin) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/capabilities", wrapper.GetCapabilities) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/server", wrapper.GetServerInfo) }) @@ -280,8 +735,738 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl return r } +type BadRequestApplicationProblemPlusJSONResponse Problem + +type ConflictApplicationProblemPlusJSONResponse Problem + +type ForbiddenResponseHeaders struct { + WWWAuthenticate *string +} +type ForbiddenApplicationProblemPlusJSONResponse struct { + Body Problem + + Headers ForbiddenResponseHeaders +} + type InternalErrorApplicationProblemPlusJSONResponse Problem +type NotFoundApplicationProblemPlusJSONResponse Problem + +type PayloadTooLargeApplicationProblemPlusJSONResponse Problem + +type TooManyRequestsResponseHeaders struct { + RetryAfter *int +} +type TooManyRequestsApplicationProblemPlusJSONResponse struct { + Body Problem + + Headers TooManyRequestsResponseHeaders +} + +type UnauthorizedResponseHeaders struct { + WWWAuthenticate *string +} +type UnauthorizedApplicationProblemPlusJSONResponse struct { + Body Problem + + Headers UnauthorizedResponseHeaders +} + +type ListGrantsRequestObject struct { + Params ListGrantsParams +} + +type ListGrantsResponseObject interface { + VisitListGrantsResponse(w http.ResponseWriter) error +} + +type ListGrants200JSONResponse GrantList + +func (response ListGrants200JSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type ListGrants400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response ListGrants400ApplicationProblemPlusJSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type ListGrants401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response ListGrants401ApplicationProblemPlusJSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type ListGrants403ApplicationProblemPlusJSONResponse struct { + ForbiddenApplicationProblemPlusJSONResponse +} + +func (response ListGrants403ApplicationProblemPlusJSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type ListGrants500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response ListGrants500ApplicationProblemPlusJSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type RevokeGrantRequestObject struct { + Id string `json:"id"` +} + +type RevokeGrantResponseObject interface { + VisitRevokeGrantResponse(w http.ResponseWriter) error +} + +type RevokeGrant204Response struct { +} + +func (response RevokeGrant204Response) VisitRevokeGrantResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type RevokeGrant400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response RevokeGrant400ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type RevokeGrant401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response RevokeGrant401ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type RevokeGrant403ApplicationProblemPlusJSONResponse struct { + ForbiddenApplicationProblemPlusJSONResponse +} + +func (response RevokeGrant403ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type RevokeGrant404ApplicationProblemPlusJSONResponse struct { + NotFoundApplicationProblemPlusJSONResponse +} + +func (response RevokeGrant404ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(404) + _, err := buf.WriteTo(w) + return err +} + +type RevokeGrant500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response RevokeGrant500ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type LoginRequestObject struct { + Body *LoginJSONRequestBody +} + +type LoginResponseObject interface { + VisitLoginResponse(w http.ResponseWriter) error +} + +type Login200ResponseHeaders struct { + CacheControl *string +} + +type Login200JSONResponse struct { + Body GrantCreated + Headers Login200ResponseHeaders +} + +func (response Login200JSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + if response.Headers.CacheControl != nil { + w.Header().Set("Cache-Control", fmt.Sprint(*response.Headers.CacheControl)) + } + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type Login400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response Login400ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type Login401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response Login401ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type Login413ApplicationProblemPlusJSONResponse struct { + PayloadTooLargeApplicationProblemPlusJSONResponse +} + +func (response Login413ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(413) + _, err := buf.WriteTo(w) + return err +} + +type Login429ApplicationProblemPlusJSONResponse struct { + TooManyRequestsApplicationProblemPlusJSONResponse +} + +func (response Login429ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.RetryAfter != nil { + w.Header().Set("Retry-After", fmt.Sprint(*response.Headers.RetryAfter)) + } + w.WriteHeader(429) + _, err := buf.WriteTo(w) + return err +} + +type Login500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response Login500ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type LogoutRequestObject struct { +} + +type LogoutResponseObject interface { + VisitLogoutResponse(w http.ResponseWriter) error +} + +type Logout200JSONResponse LogoutResponse + +func (response Logout200JSONResponse) VisitLogoutResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type Logout401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response Logout401ApplicationProblemPlusJSONResponse) VisitLogoutResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type Logout403ApplicationProblemPlusJSONResponse struct { + ForbiddenApplicationProblemPlusJSONResponse +} + +func (response Logout403ApplicationProblemPlusJSONResponse) VisitLogoutResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type Logout500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response Logout500ApplicationProblemPlusJSONResponse) VisitLogoutResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type ChangePasswordRequestObject struct { + Body *ChangePasswordJSONRequestBody +} + +type ChangePasswordResponseObject interface { + VisitChangePasswordResponse(w http.ResponseWriter) error +} + +type ChangePassword204Response struct { +} + +func (response ChangePassword204Response) VisitChangePasswordResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type ChangePassword400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response ChangePassword400ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type ChangePassword401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response ChangePassword401ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type ChangePassword403ApplicationProblemPlusJSONResponse struct { + ForbiddenApplicationProblemPlusJSONResponse +} + +func (response ChangePassword403ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type ChangePassword409ApplicationProblemPlusJSONResponse struct { + ConflictApplicationProblemPlusJSONResponse +} + +func (response ChangePassword409ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(409) + _, err := buf.WriteTo(w) + return err +} + +type ChangePassword413ApplicationProblemPlusJSONResponse struct { + PayloadTooLargeApplicationProblemPlusJSONResponse +} + +func (response ChangePassword413ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(413) + _, err := buf.WriteTo(w) + return err +} + +type ChangePassword429ApplicationProblemPlusJSONResponse struct { + TooManyRequestsApplicationProblemPlusJSONResponse +} + +func (response ChangePassword429ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.RetryAfter != nil { + w.Header().Set("Retry-After", fmt.Sprint(*response.Headers.RetryAfter)) + } + w.WriteHeader(429) + _, err := buf.WriteTo(w) + return err +} + +type ChangePassword500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response ChangePassword500ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type SetupFirstAdminRequestObject struct { + Body *SetupFirstAdminJSONRequestBody +} + +type SetupFirstAdminResponseObject interface { + VisitSetupFirstAdminResponse(w http.ResponseWriter) error +} + +type SetupFirstAdmin201ResponseHeaders struct { + CacheControl *string +} + +type SetupFirstAdmin201JSONResponse struct { + Body GrantCreated + Headers SetupFirstAdmin201ResponseHeaders +} + +func (response SetupFirstAdmin201JSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + if response.Headers.CacheControl != nil { + w.Header().Set("Cache-Control", fmt.Sprint(*response.Headers.CacheControl)) + } + w.WriteHeader(201) + _, err := buf.WriteTo(w) + return err +} + +type SetupFirstAdmin400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response SetupFirstAdmin400ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type SetupFirstAdmin409ApplicationProblemPlusJSONResponse struct { + ConflictApplicationProblemPlusJSONResponse +} + +func (response SetupFirstAdmin409ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(409) + _, err := buf.WriteTo(w) + return err +} + +type SetupFirstAdmin413ApplicationProblemPlusJSONResponse struct { + PayloadTooLargeApplicationProblemPlusJSONResponse +} + +func (response SetupFirstAdmin413ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(413) + _, err := buf.WriteTo(w) + return err +} + +type SetupFirstAdmin429ApplicationProblemPlusJSONResponse struct { + TooManyRequestsApplicationProblemPlusJSONResponse +} + +func (response SetupFirstAdmin429ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.RetryAfter != nil { + w.Header().Set("Retry-After", fmt.Sprint(*response.Headers.RetryAfter)) + } + w.WriteHeader(429) + _, err := buf.WriteTo(w) + return err +} + +type SetupFirstAdmin500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response SetupFirstAdmin500ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type GetCapabilitiesRequestObject struct { +} + +type GetCapabilitiesResponseObject interface { + VisitGetCapabilitiesResponse(w http.ResponseWriter) error +} + +type GetCapabilities200JSONResponse Capabilities + +func (response GetCapabilities200JSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetCapabilities401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response GetCapabilities401ApplicationProblemPlusJSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type GetCapabilities500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response GetCapabilities500ApplicationProblemPlusJSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + type GetServerInfoRequestObject struct { } @@ -321,6 +1506,27 @@ func (response GetServerInfo500ApplicationProblemPlusJSONResponse) VisitGetServe // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // ListGrants List my grants + // (GET /auth/grants) + ListGrants(ctx context.Context, request ListGrantsRequestObject) (ListGrantsResponseObject, error) + // RevokeGrant Revoke one of my grants + // (DELETE /auth/grants/{id}) + RevokeGrant(ctx context.Context, request RevokeGrantRequestObject) (RevokeGrantResponseObject, error) + // Login Log in with a password + // (POST /auth/login) + Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error) + // Logout Log out + // (POST /auth/logout) + Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) + // ChangePassword Change my password + // (POST /auth/password) + ChangePassword(ctx context.Context, request ChangePasswordRequestObject) (ChangePasswordResponseObject, error) + // SetupFirstAdmin Create the first admin + // (POST /auth/setup) + SetupFirstAdmin(ctx context.Context, request SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error) + // GetCapabilities List implemented capability modules + // (GET /capabilities) + GetCapabilities(ctx context.Context, request GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) // GetServerInfo Describe the server // (GET /server) GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error) @@ -365,6 +1571,199 @@ type strictHandler struct { options StrictHTTPServerOptions } +// ListGrants operation middleware +func (sh *strictHandler) ListGrants(w http.ResponseWriter, r *http.Request, params ListGrantsParams) { + var request ListGrantsRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListGrants(ctx, request.(ListGrantsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListGrants") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListGrantsResponseObject); ok { + if err := validResponse.VisitListGrantsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// RevokeGrant operation middleware +func (sh *strictHandler) RevokeGrant(w http.ResponseWriter, r *http.Request, id string) { + var request RevokeGrantRequestObject + + request.Id = id + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.RevokeGrant(ctx, request.(RevokeGrantRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RevokeGrant") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(RevokeGrantResponseObject); ok { + if err := validResponse.VisitRevokeGrantResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// Login operation middleware +func (sh *strictHandler) Login(w http.ResponseWriter, r *http.Request) { + var request LoginRequestObject + + var body LoginJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.Login(ctx, request.(LoginRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Login") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(LoginResponseObject); ok { + if err := validResponse.VisitLoginResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// Logout operation middleware +func (sh *strictHandler) Logout(w http.ResponseWriter, r *http.Request) { + var request LogoutRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.Logout(ctx, request.(LogoutRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Logout") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(LogoutResponseObject); ok { + if err := validResponse.VisitLogoutResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ChangePassword operation middleware +func (sh *strictHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + var request ChangePasswordRequestObject + + var body ChangePasswordJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ChangePassword(ctx, request.(ChangePasswordRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ChangePassword") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ChangePasswordResponseObject); ok { + if err := validResponse.VisitChangePasswordResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// SetupFirstAdmin operation middleware +func (sh *strictHandler) SetupFirstAdmin(w http.ResponseWriter, r *http.Request) { + var request SetupFirstAdminRequestObject + + var body SetupFirstAdminJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.SetupFirstAdmin(ctx, request.(SetupFirstAdminRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SetupFirstAdmin") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(SetupFirstAdminResponseObject); ok { + if err := validResponse.VisitSetupFirstAdminResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetCapabilities operation middleware +func (sh *strictHandler) GetCapabilities(w http.ResponseWriter, r *http.Request) { + var request GetCapabilitiesRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetCapabilities(ctx, request.(GetCapabilitiesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetCapabilities") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetCapabilitiesResponseObject); ok { + if err := validResponse.VisitGetCapabilitiesResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetServerInfo operation middleware func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) { var request GetServerInfoRequestObject diff --git a/server/apiv1/api_test.go b/server/apiv1/api_test.go index 2552e40b0..3851db3d1 100644 --- a/server/apiv1/api_test.go +++ b/server/apiv1/api_test.go @@ -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)) diff --git a/server/apiv1/apiv1_suite_test.go b/server/apiv1/apiv1_suite_test.go index f89244e38..00b26abc4 100644 --- a/server/apiv1/apiv1_suite_test.go +++ b/server/apiv1/apiv1_suite_test.go @@ -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) { diff --git a/server/apiv1/auth_handlers.go b/server/apiv1/auth_handlers.go new file mode 100644 index 000000000..eec4a3dae --- /dev/null +++ b/server/apiv1/auth_handlers.go @@ -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 +} diff --git a/server/apiv1/auth_handlers_test.go b/server/apiv1/auth_handlers_test.go new file mode 100644 index 000000000..48c2748ad --- /dev/null +++ b/server/apiv1/auth_handlers_test.go @@ -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...) +} diff --git a/server/apiv1/db_test.go b/server/apiv1/db_test.go new file mode 100644 index 000000000..6b292a10b --- /dev/null +++ b/server/apiv1/db_test.go @@ -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") +} diff --git a/server/apiv1/dto.go b/server/apiv1/dto.go new file mode 100644 index 000000000..c65830a6a --- /dev/null +++ b/server/apiv1/dto.go @@ -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 +} diff --git a/server/apiv1/dto_test.go b/server/apiv1/dto_test.go new file mode 100644 index 000000000..49f10f067 --- /dev/null +++ b/server/apiv1/dto_test.go @@ -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) + } + }) +}) diff --git a/server/apiv1/gate.go b/server/apiv1/gate.go new file mode 100644 index 000000000..9d6c20716 --- /dev/null +++ b/server/apiv1/gate.go @@ -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" + } +} diff --git a/server/apiv1/gate_test.go b/server/apiv1/gate_test.go new file mode 100644 index 000000000..f748957fc --- /dev/null +++ b/server/apiv1/gate_test.go @@ -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) + } +} diff --git a/server/apiv1/oapi-codegen-overlay.yaml b/server/apiv1/oapi-codegen-overlay.yaml new file mode 100644 index 000000000..36a546287 --- /dev/null +++ b/server/apiv1/oapi-codegen-overlay.yaml @@ -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 diff --git a/server/apiv1/oapi-codegen.yaml b/server/apiv1/oapi-codegen.yaml index b9236de1a..8301d8088 100644 --- a/server/apiv1/oapi-codegen.yaml +++ b/server/apiv1/oapi-codegen.yaml @@ -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 diff --git a/server/apiv1/problem.go b/server/apiv1/problem.go index aa6389357..fe5c8169b 100644 --- a/server/apiv1/problem.go +++ b/server/apiv1/problem.go @@ -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...) } diff --git a/server/apiv1/problem_test.go b/server/apiv1/problem_test.go index 256296b3c..95ee46668 100644 --- a/server/apiv1/problem_test.go +++ b/server/apiv1/problem_test.go @@ -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) diff --git a/server/apiv1/reference.go b/server/apiv1/reference.go new file mode 100644 index 000000000..bc614878c --- /dev/null +++ b/server/apiv1/reference.go @@ -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)) + }) +} diff --git a/server/apiv1/server_info.go b/server/apiv1/server_handlers.go similarity index 62% rename from server/apiv1/server_info.go rename to server/apiv1/server_handlers.go index 458363efe..f5c159629 100644 --- a/server/apiv1/server_info.go +++ b/server/apiv1/server_handlers.go @@ -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 } diff --git a/server/apiv1/server_info_test.go b/server/apiv1/server_handlers_test.go similarity index 63% rename from server/apiv1/server_info_test.go rename to server/apiv1/server_handlers_test.go index b9dae6f8d..59d0dcb3d 100644 --- a/server/apiv1/server_info_test.go +++ b/server/apiv1/server_handlers_test.go @@ -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)) + }) +}) diff --git a/server/auth.go b/server/auth.go index 3e58359da..e772c1da2 100644 --- a/server/auth.go +++ b/server/auth.go @@ -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) { diff --git a/server/auth_test.go b/server/auth_test.go index 1095fafc9..b7db3a881 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -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")) diff --git a/server/middlewares.go b/server/middlewares.go index b65a2d6e1..149ddbfc3 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -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 diff --git a/server/middlewares_test.go b/server/middlewares_test.go index 15cf70341..55f842914 100644 --- a/server/middlewares_test.go +++ b/server/middlewares_test.go @@ -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" diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index a5e4126cf..79b8529b4 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -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) } diff --git a/tests/mock_grant_repo.go b/tests/mock_grant_repo.go new file mode 100644 index 000000000..be9134ece --- /dev/null +++ b/tests/mock_grant_repo.go @@ -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 +}