diff --git a/api/.vacuum.yaml b/api/.vacuum.yaml index bd31e23f8..f658b6c01 100644 --- a/api/.vacuum.yaml +++ b/api/.vacuum.yaml @@ -36,6 +36,22 @@ 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-x-scope: + description: An operation's x-scope is a known scope. Cross-checks with x-module and security run in Go (server/apiv1 newGate). + severity: error + given: $.paths[*][get,put,post,delete,patch]['x-scope'] + then: + function: enumeration + functionOptions: + values: [read, password] 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 51b5f1e6d..0a5d889c8 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 /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\nOperations that need an access token declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted\nfrom it, stops working within one access-token lifetime 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, access tokens, and login methods." } ], "paths": { @@ -83,6 +87,58 @@ } } }, + "/auth/token": { + "post": { + "operationId": "createAccessToken", + "x-module": "core", + "x-stability-level": "alpha", + "tags": [ + "auth" + ], + "summary": "Mint an access token", + "description": "Turns a grant into a short-lived access token, optionally narrowed to a subset of the grant's scopes. Send the grant secret as the Bearer credential.", + "security": [ + { + "grantAuth": [] + } + ], + "requestBody": { + "description": "Scopes to narrow the token to. Optional.", + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The new access token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessToken" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/openapi.yaml": { "get": { "operationId": "getOpenAPISpecYAML", @@ -116,6 +172,302 @@ } } } + }, + "/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" + } + } + } + }, + "/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 and every token minted from it. 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.", + "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.", + "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. 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": { @@ -123,8 +475,12 @@ "bearerAuth": { "type": "http", "scheme": "bearer", - "bearerFormat": "JWT", - "description": "Short-lived access token minted from a device grant. Not yet applied to any operation." + "description": "Short-lived access token from `POST /auth/token`. Opaque. The required scope is in each operation's `x-scope`." + }, + "grantAuth": { + "type": "http", + "scheme": "bearer", + "description": "Long-lived grant secret. Accepted only by `POST /auth/token`." } }, "schemas": { @@ -190,7 +546,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", @@ -241,6 +597,320 @@ "description": "Why the value was rejected." } } + }, + "TokenRequest": { + "type": "object", + "description": "Optional narrowing of a new access token.", + "properties": { + "scopes": { + "type": "array", + "maxItems": 32, + "description": "Subset of the grant's scopes. Omit for all of them; an empty list asks for none.", + "items": { + "$ref": "#/components/schemas/ScopeRequest" + } + } + } + }, + "AccessToken": { + "type": "object", + "description": "A short-lived access token. Opaque; clients must not decode it.", + "required": [ + "accessToken", + "tokenType", + "expiresIn", + "scopes" + ], + "properties": { + "accessToken": { + "type": "string", + "description": "The token. Send it as `Authorization: Bearer \u003ctoken\u003e`." + }, + "tokenType": { + "type": "string", + "enum": [ + "Bearer" + ], + "description": "Always `Bearer`." + }, + "expiresIn": { + "type": "integer", + "description": "Seconds until the token expires." + }, + "scopes": { + "type": "array", + "description": "Scopes the token actually carries, which may be fewer than requested.", + "items": { + "$ref": "#/components/schemas/Scope" + } + } + } + }, + "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 a Bearer credential to `POST /auth/token`." + }, + "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." + } + } + }, + "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 + }, + "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" + ] + }, + "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 may mint tokens for.", + "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." + } + } + }, + "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": { @@ -261,6 +931,119 @@ "$ref": "#/components/headers/ETag" } } + }, + "BadRequest": { + "description": "The request is malformed or fails validation.", + "content": { + "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" + } + } + } + }, + "PayloadTooLarge": { + "description": "The request body is too large (`payload_too_large`).", + "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" + } + } + } + }, + "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": { @@ -269,6 +1052,12 @@ "schema": { "type": "string" } + }, + "WWWAuthenticate": { + "description": "RFC 6750 Bearer challenge, for example `Bearer error=\"insufficient_scope\", scope=\"read\"`.", + "schema": { + "type": "string" + } } } } diff --git a/api/bundled/openapi.yaml b/api/bundled/openapi.yaml index 3f19d5a72..778791f97 100644 --- a/api/bundled/openapi.yaml +++ b/api/bundled/openapi.yaml @@ -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 an access token declare `security: [{bearerAuth: []}]` and the scope they need in + `x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted + from it, stops working within one access-token lifetime 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, access tokens, and login methods. paths: /server: get: @@ -67,6 +73,37 @@ paths: description: OpenAPI 3.0 document. '304': $ref: '#/components/responses/NotModified' + /auth/token: + post: + operationId: createAccessToken + x-module: core + x-stability-level: alpha + tags: [auth] + summary: Mint an access token + description: "Turns a grant into a short-lived access token, optionally narrowed to a subset of the grant's scopes. Send the grant secret as the Bearer credential." + security: [{grantAuth: []}] + requestBody: + description: Scopes to narrow the token to. Optional. + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRequest' + responses: + '200': + description: The new access token. + content: + application/json: + schema: + $ref: '#/components/schemas/AccessToken' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '500': + $ref: '#/components/responses/InternalError' /openapi.yaml: get: operationId: getOpenAPISpecYAML @@ -89,13 +126,198 @@ 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' + /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 and every token minted from it. 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. + 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. + 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. 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: "Short-lived access token from `POST /auth/token`. Opaque. The required scope is in each operation's `x-scope`." + grantAuth: + type: http + scheme: bearer + description: "Long-lived grant secret. Accepted only by `POST /auth/token`." schemas: ServerInfo: type: object @@ -148,7 +370,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. @@ -187,6 +409,244 @@ components: message: type: string description: Why the value was rejected. + TokenRequest: + type: object + description: Optional narrowing of a new access token. + properties: + scopes: + type: array + maxItems: 32 + description: "Subset of the grant's scopes. Omit for all of them; an empty list asks for none." + items: + $ref: '#/components/schemas/ScopeRequest' + AccessToken: + type: object + description: "A short-lived access token. Opaque; clients must not decode it." + required: + - accessToken + - tokenType + - expiresIn + - scopes + properties: + accessToken: + type: string + description: "The token. Send it as `Authorization: Bearer `." + tokenType: + type: string + enum: + - Bearer + description: "Always `Bearer`." + expiresIn: + type: integer + description: Seconds until the token expires. + scopes: + type: array + description: "Scopes the token actually carries, which may be fewer than requested." + items: + $ref: '#/components/schemas/Scope' + 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 a Bearer credential to `POST /auth/token`." + 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." + 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 + 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 + 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 may mint tokens for. + 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. + 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. @@ -199,8 +659,85 @@ components: headers: ETag: $ref: '#/components/headers/ETag' + BadRequest: + description: The request is malformed or fails validation. + content: + 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' + PayloadTooLarge: + description: "The request body is too large (`payload_too_large`)." + 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' + 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: ETag: description: Entity tag for `If-None-Match` revalidation. schema: type: string + WWWAuthenticate: + description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.' + schema: + type: string 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/AccessToken.yaml b/api/openapi/components/schemas/AccessToken.yaml new file mode 100644 index 000000000..5a692aa13 --- /dev/null +++ b/api/openapi/components/schemas/AccessToken.yaml @@ -0,0 +1,19 @@ +type: object +description: "A short-lived access token. Opaque; clients must not decode it." +required: [accessToken, tokenType, expiresIn, scopes] +properties: + accessToken: + type: string + description: "The token. Send it as `Authorization: Bearer `." + tokenType: + type: string + enum: [Bearer] + description: "Always `Bearer`." + expiresIn: + type: integer + description: Seconds until the token expires. + scopes: + type: array + description: "Scopes the token actually carries, which may be fewer than requested." + items: + $ref: ./Scope.yaml 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/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..54d5a3473 --- /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 may mint tokens for. + 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..eba757e90 --- /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 a Bearer credential to `POST /auth/token`." + 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/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/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/Problem.yaml b/api/openapi/components/schemas/Problem.yaml index b2224432a..c9030e7b0 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. 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/TokenRequest.yaml b/api/openapi/components/schemas/TokenRequest.yaml new file mode 100644 index 000000000..12a0b9c73 --- /dev/null +++ b/api/openapi/components/schemas/TokenRequest.yaml @@ -0,0 +1,9 @@ +type: object +description: Optional narrowing of a new access token. +properties: + scopes: + type: array + maxItems: 32 + description: "Subset of the grant's scopes. Omit for all of them; an empty list asks for none." + items: + $ref: ./ScopeRequest.yaml diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 73cbb7b27..02d3a56a4 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -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 an access token declare `security: [{bearerAuth: []}]` and the scope they need in + `x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted + from it, stops working within one access-token lifetime 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, access tokens, and login methods. paths: /server: $ref: ./paths/server.yaml @@ -30,10 +36,27 @@ paths: $ref: ./paths/openapi.yaml#/json /openapi.yaml: $ref: ./paths/openapi.yaml#/yaml + /auth/token: + $ref: ./paths/auth.yaml#/token + /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: "Short-lived access token from `POST /auth/token`. Opaque. The required scope is in each operation's `x-scope`." + grantAuth: + type: http + scheme: bearer + description: "Long-lived grant secret. Accepted only by `POST /auth/token`." diff --git a/api/openapi/paths/auth.yaml b/api/openapi/paths/auth.yaml new file mode 100644 index 000000000..9a3aedf37 --- /dev/null +++ b/api/openapi/paths/auth.yaml @@ -0,0 +1,213 @@ +token: + post: + operationId: createAccessToken + x-module: core + x-stability-level: alpha + tags: [auth] + summary: Mint an access token + description: "Turns a grant into a short-lived access token, optionally narrowed to a subset of the grant's scopes. Send the grant secret as the Bearer credential." + security: [{grantAuth: []}] + requestBody: + description: Scopes to narrow the token to. Optional. + required: false + content: + application/json: + schema: + $ref: ../components/schemas/TokenRequest.yaml + responses: + '200': + description: The new access token. + content: + application/json: + schema: + $ref: ../components/schemas/AccessToken.yaml + '400': + $ref: ../components/responses/BadRequest.yaml + '401': + $ref: ../components/responses/Unauthorized.yaml + '413': + $ref: ../components/responses/PayloadTooLarge.yaml + '500': + $ref: ../components/responses/InternalError.yaml +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 and every token minted from it. 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. + 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. + 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. 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/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/server/apiv1/api_gen.go b/server/apiv1/api_gen.go index cffd7ece0..9851e485c 100644 --- a/server/apiv1/api_gen.go +++ b/server/apiv1/api_gen.go @@ -7,12 +7,31 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "io" "net/http" + "time" "github.com/go-chi/chi/v5" + "github.com/oapi-codegen/runtime" ) +// Defines values for AccessTokenTokenType. +const ( + AccessTokenTokenTypeBearer AccessTokenTokenType = "Bearer" +) + +// Valid indicates whether the value is a known member of the AccessTokenTokenType enum. +func (e AccessTokenTokenType) Valid() bool { + switch e { + case AccessTokenTokenTypeBearer: + return true + default: + return false + } +} + // Defines values for ProblemCode. const ( ProblemCodeForbidden ProblemCode = "forbidden" @@ -64,6 +83,27 @@ func (e ProblemCode) Valid() bool { } } +// Defines values for Scope. +const ( + ScopeAll Scope = "all" + ScopePassword Scope = "password" + ScopeRead Scope = "read" +) + +// Valid indicates whether the value is a known member of the Scope enum. +func (e Scope) Valid() bool { + switch e { + case ScopeAll: + return true + case ScopePassword: + return true + case ScopeRead: + return true + default: + return false + } +} + // Defines values for ServerInfoLoginMethods. const ( ServerInfoLoginMethodsPassword ServerInfoLoginMethods = "password" @@ -79,12 +119,147 @@ func (e ServerInfoLoginMethods) Valid() bool { } } +// AccessToken A short-lived access token. Opaque; clients must not decode it. +type AccessToken struct { + // AccessToken The token. Send it as `Authorization: Bearer `. + AccessToken string `json:"accessToken"` + + // ExpiresIn Seconds until the token expires. + ExpiresIn int `json:"expiresIn"` + + // Scopes Scopes the token actually carries, which may be fewer than requested. + Scopes []Scope `json:"scopes"` + + // TokenType Always `Bearer`. + TokenType AccessTokenTokenType `json:"tokenType"` +} + +// AccessTokenTokenType Always `Bearer`. +type AccessTokenTokenType string + +// 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"` +} + +// 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 may mint tokens for. + 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 a Bearer credential to `POST /auth/token`. + 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"` +} + +// 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"` +} + +// 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"` +} + // 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`. @@ -108,6 +283,13 @@ 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. @@ -129,6 +311,12 @@ type ServerInfo struct { // ServerInfoLoginMethods defines model for ServerInfo.LoginMethods. type ServerInfoLoginMethods string +// TokenRequest Optional narrowing of a new access token. +type TokenRequest struct { + // Scopes Subset of the grant's scopes. Omit for all of them; an empty list asks for none. + Scopes *[]ScopeRequest `json:"scopes,omitempty"` +} + // ValidationError One field-level validation failure. type ValidationError struct { // Field Name of the offending query parameter, path parameter, or body field (dotted for nested). @@ -138,11 +326,80 @@ 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 + +// CreateAccessTokenJSONRequestBody defines body for CreateAccessToken for application/json ContentType. +type CreateAccessTokenJSONRequestBody = TokenRequest + // 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) + // CreateAccessToken Mint an access token + // (POST /auth/token) + CreateAccessToken(w http.ResponseWriter, r *http.Request) // GetServerInfo Describe the server // (GET /server) GetServerInfo(w http.ResponseWriter, r *http.Request) @@ -152,6 +409,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) +} + +// CreateAccessToken Mint an access token +// (POST /auth/token) +func (_ Unimplemented) CreateAccessToken(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) { @@ -167,6 +466,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) +} + +// CreateAccessToken operation middleware +func (siw *ServerInterfaceWrapper) CreateAccessToken(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateAccessToken(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) { @@ -294,6 +735,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.Post(options.BaseURL+"/auth/token", wrapper.CreateAccessToken) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/server", wrapper.GetServerInfo) }) @@ -301,8 +763,751 @@ 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 Login200JSONResponse GrantCreated + +func (response Login200JSONResponse) 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/json") + 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 SetupFirstAdmin201JSONResponse GrantCreated + +func (response SetupFirstAdmin201JSONResponse) 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/json") + 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 CreateAccessTokenRequestObject struct { + Body *CreateAccessTokenJSONRequestBody +} + +type CreateAccessTokenResponseObject interface { + VisitCreateAccessTokenResponse(w http.ResponseWriter) error +} + +type CreateAccessToken200JSONResponse AccessToken + +func (response CreateAccessToken200JSONResponse) VisitCreateAccessTokenResponse(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 CreateAccessToken400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response CreateAccessToken400ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(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 CreateAccessToken401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response CreateAccessToken401ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(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 CreateAccessToken413ApplicationProblemPlusJSONResponse struct { + PayloadTooLargeApplicationProblemPlusJSONResponse +} + +func (response CreateAccessToken413ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(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 CreateAccessToken500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response CreateAccessToken500ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(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 { } @@ -342,6 +1547,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) + // CreateAccessToken Mint an access token + // (POST /auth/token) + CreateAccessToken(ctx context.Context, request CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error) // GetServerInfo Describe the server // (GET /server) GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error) @@ -386,6 +1612,209 @@ 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)) + } +} + +// CreateAccessToken operation middleware +func (sh *strictHandler) CreateAccessToken(w http.ResponseWriter, r *http.Request) { + var request CreateAccessTokenRequestObject + + var body CreateAccessTokenJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + if !errors.Is(err, io.EOF) { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + } else { + request.Body = &body + } + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.CreateAccessToken(ctx, request.(CreateAccessTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CreateAccessToken") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(CreateAccessTokenResponseObject); ok { + if err := validResponse.VisitCreateAccessTokenResponse(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/apiv1_suite_test.go b/server/apiv1/apiv1_suite_test.go index f89244e38..6875b2330 100644 --- a/server/apiv1/apiv1_suite_test.go +++ b/server/apiv1/apiv1_suite_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "path/filepath" "testing" "github.com/getkin/kin-openapi/openapi3" @@ -14,7 +15,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 +34,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) diff --git a/server/apiv1/auth_handlers.go b/server/apiv1/auth_handlers.go new file mode 100644 index 000000000..543b1e446 --- /dev/null +++ b/server/apiv1/auth_handlers.go @@ -0,0 +1,71 @@ +package apiv1 + +import ( + "cmp" + "context" + + "github.com/navidrome/navidrome/core/apiauth" +) + +const defaultPageSize = 100 + +func (rt *Router) CreateAccessToken(ctx context.Context, req CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error) { + p, err := principal(apiauth.PrincipalFrom(ctx)) + if err != nil { + return nil, err + } + var requested []string + if req.Body != nil { + requested = fromScopeRequests(req.Body.Scopes) + } + tok, err := rt.auth.Mint(ctx, p, requested) + if err != nil { + return nil, err + } + return CreateAccessToken200JSONResponse{ + AccessToken: tok.Token, + TokenType: AccessTokenTokenTypeBearer, + ExpiresIn: int(tok.ExpiresIn.Seconds()), + Scopes: toScopes(tok.Scopes), + }, nil +} + +func (rt *Router) ListGrants(ctx context.Context, req ListGrantsRequestObject) (ListGrantsResponseObject, error) { + p, err := principal(apiauth.PrincipalFrom(ctx)) + if err != nil { + return nil, err + } + offset := deref(req.Params.OffsetParam) + limit := cmp.Or(deref(req.Params.LimitParam), defaultPageSize) + grants, total, err := rt.auth.ListGrants(ctx, p, offset, limit) + if err != nil { + return nil, err + } + items := make([]Grant, len(grants)) + for i, g := range grants { + items[i] = 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 := principal(apiauth.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 := principal(apiauth.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_test.go b/server/apiv1/auth_test.go new file mode 100644 index 000000000..da45a2141 --- /dev/null +++ b/server/apiv1/auth_test.go @@ -0,0 +1,234 @@ +package apiv1 + +import ( + "bytes" + "context" + "encoding/json" + "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 router *Router + + call := func(method, path, bearer string, body any) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + b, _ := json.Marshal(body) + req = httptest.NewRequestWithContext(ctx, method, path, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequestWithContext(ctx, method, path, nil) + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + return serve(router, req) + } + + creds := func(user, pw string) map[string]any { + return map[string]any{"username": user, "password": pw, "client": "TestApp", "clientVersion": "1.0"} + } + + decode := func(w *httptest.ResponseRecorder, v any) { + ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), v)).To(Succeed(), w.Body.String()) + } + + setup := func() GrantCreated { + w := call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw")) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusCreated), w.Body.String()) + var gc GrantCreated + decode(w, &gc) + return gc + } + + mint := func(secret string, body any) AccessToken { + w := call(http.MethodPost, "/api/v1/auth/token", secret, body) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK), w.Body.String()) + var at AccessToken + decode(w, &at) + return at + } + + BeforeEach(func() { + ctx = GinkgoT().Context() + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 0 + resetDB() + 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 = call(http.MethodPost, "/api/v1/auth/setup", "", creds("v1admin", "pw")).Code + }() + go func() { + defer GinkgoRecover() + defer wg.Done() + v0Err = realDS.WithTxImmediate(func(tx model.DataStore) error { // what v0 /auth/createAdmin runs + _, err := auth.CreateFirstAdmin(ctx, tx, "v0admin", "pw") + return err + }) + }() + wg.Wait() + Expect(realDS.User().CountAll(ctx)).To(Equal(int64(1))) + 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 := 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 := 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, mints a token, and uses it on a scoped endpoint", func() { + setup() + w := call(http.MethodPost, "/api/v1/auth/login", "", creds("ADMIN", "pw")) + Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) + var gc GrantCreated + decode(w, &gc) + Expect(gc.User.PasswordChangeable).To(BeTrue()) + + at := mint(gc.Secret, nil) + Expect(at.TokenType).To(Equal(AccessTokenTokenTypeBearer)) + Expect(at.ExpiresIn).To(Equal(3600)) + + w = call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil) + Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) + var list GrantList + decode(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() { + setup() + a := call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "wrong")) + b := 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 no body and {} as all scopes, and [] as no scopes", func() { + gc := setup() + all := mint(gc.Secret, nil) + Expect(all.Scopes).To(ConsistOf(ScopeRead, ScopePassword)) + Expect(mint(gc.Secret, map[string]any{}).Scopes).To(ConsistOf(ScopeRead, ScopePassword)) + + none := mint(gc.Secret, map[string]any{"scopes": []string{}}) + Expect(none.Scopes).To(BeEmpty()) + w := call(http.MethodGet, "/api/v1/auth/grants", none.AccessToken, nil) + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope)) + }) + + It("drops unknown requested scopes instead of rejecting them", func() { + gc := setup() + at := mint(gc.Secret, map[string]any{"scopes": []string{"read", "playlists:write"}}) + Expect(at.Scopes).To(ConsistOf(ScopeRead)) + }) + + It("does not let a token without read log out or revoke grants", func() { + gc := setup() + narrow := mint(gc.Secret, map[string]any{"scopes": []string{"password"}}) + Expect(call(http.MethodPost, "/api/v1/auth/logout", narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden)) + Expect(call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden)) + }) + + It("logs out: the token stops at once and logoutUrl is null", func() { + gc := setup() + at := mint(gc.Secret, nil) + w := call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`)) + + w = call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("answers 404 for a grant id the caller does not own, and 400 for an over-long id", func() { + gc := setup() + tok := mint(gc.Secret, nil).AccessToken + Expect(call(http.MethodDelete, "/api/v1/auth/grants/does-not-exist", tok, nil).Code).To(Equal(http.StatusNotFound)) + w := call(http.MethodDelete, "/api/v1/auth/grants/"+strings.Repeat("x", 65), tok, 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 := setup() + otherLogin := call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw")) + var other GrantCreated + decode(otherLogin, &other) + at := mint(gc.Secret, nil) + + w := call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) + Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String()) + + Expect(call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) + Expect(call(http.MethodPost, "/api/v1/auth/token", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("reports a wrong current password as a field error", func() { + gc := setup() + at := mint(gc.Secret, nil) + w := call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, 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 := 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(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)) }), + ) +}) 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..10ec338b1 --- /dev/null +++ b/server/apiv1/dto.go @@ -0,0 +1,78 @@ +package apiv1 + +import ( + "github.com/navidrome/navidrome/core/apiauth" + "github.com/navidrome/navidrome/model" +) + +func toScopes(in []string) []Scope { + out := make([]Scope, len(in)) + for i, s := range in { + out[i] = Scope(s) + } + return out +} + +// 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: deref(c.Name), ClientVersion: deref(c.ClientVersion)} +} + +// principal fails closed if the gate did not attach a principal to the context. +func principal(p *apiauth.Principal, ok bool) (*apiauth.Principal, error) { + if !ok || p == nil { + return nil, model.ErrInvalidAuth + } + return p, nil +} + +func deref[T any](p *T) T { + var zero T + if p == nil { + return zero + } + return *p +} 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/password_handlers.go b/server/apiv1/password_handlers.go new file mode 100644 index 000000000..a96d905d0 --- /dev/null +++ b/server/apiv1/password_handlers.go @@ -0,0 +1,47 @@ +package apiv1 + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/core/apiauth" +) + +// 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(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(toGrantCreated(issued)), nil +} + +func (rt *Router) ChangePassword(ctx context.Context, req ChangePasswordRequestObject) (ChangePasswordResponseObject, error) { + p, err := principal(apiauth.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 +}