mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
feat(api): add API v1 login, setup, token, grant and password endpoints
Adds the seven auth operations to the spec (createAccessToken, listGrants, revokeGrant and logout in core; login, setupFirstAdmin and changePassword in the password module), the bearerAuth/grantAuth schemes, and vacuum rules requiring explicit security and a known x-scope. The strict handlers sit on core/apiauth and are covered end to end against a real SQLite database. The first operations with parameters make the generated code import github.com/oapi-codegen/runtime. An oapi-codegen overlay renames the shared offset/limit parameter types, since a generated Offset clashes with Ginkgo's dot-imported Offset in this package's tests; the published spec is unchanged.
This commit is contained in:
parent
4f2d350757
commit
294c1a9a6a
34 changed files with 3741 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <token>`."
|
||||
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
|
||||
|
|
|
|||
3
api/openapi/components/headers/WWWAuthenticate.yaml
Normal file
3
api/openapi/components/headers/WWWAuthenticate.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.'
|
||||
schema:
|
||||
type: string
|
||||
5
api/openapi/components/responses/Conflict.yaml
Normal file
5
api/openapi/components/responses/Conflict.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`."
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
5
api/openapi/components/responses/PayloadTooLarge.yaml
Normal file
5
api/openapi/components/responses/PayloadTooLarge.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: "The request body is too large (`payload_too_large`)."
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
10
api/openapi/components/responses/TooManyRequests.yaml
Normal file
10
api/openapi/components/responses/TooManyRequests.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
description: "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds."
|
||||
headers:
|
||||
Retry-After:
|
||||
description: Seconds to wait before retrying.
|
||||
schema:
|
||||
type: integer
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
description: Missing, invalid, or expired credentials.
|
||||
headers:
|
||||
WWW-Authenticate:
|
||||
$ref: ../headers/WWWAuthenticate.yaml
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
|
|
|
|||
19
api/openapi/components/schemas/AccessToken.yaml
Normal file
19
api/openapi/components/schemas/AccessToken.yaml
Normal file
|
|
@ -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 <token>`."
|
||||
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
|
||||
19
api/openapi/components/schemas/AuthUser.yaml
Normal file
19
api/openapi/components/schemas/AuthUser.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
type: object
|
||||
description: The user a grant belongs to.
|
||||
required: [id, userName, name, isAdmin, passwordChangeable]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: User id.
|
||||
userName:
|
||||
type: string
|
||||
description: Login name.
|
||||
name:
|
||||
type: string
|
||||
description: Display name.
|
||||
isAdmin:
|
||||
type: boolean
|
||||
description: Whether the user is an administrator.
|
||||
passwordChangeable:
|
||||
type: boolean
|
||||
description: "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false."
|
||||
34
api/openapi/components/schemas/CredentialsRequest.yaml
Normal file
34
api/openapi/components/schemas/CredentialsRequest.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
type: object
|
||||
description: "Username, password and client description for a login or first-admin setup."
|
||||
required: [username, password, client]
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
description: Login name.
|
||||
password:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
description: Password.
|
||||
client:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
description: Name of the client app.
|
||||
clientVersion:
|
||||
type: string
|
||||
maxLength: 32
|
||||
description: Version of the client app.
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
description: "Label for this grant. Defaults to `client`."
|
||||
scopes:
|
||||
type: array
|
||||
maxItems: 32
|
||||
description: "Scopes the grant may hold. Omit for `all`."
|
||||
items:
|
||||
$ref: ./ScopeRequest.yaml
|
||||
41
api/openapi/components/schemas/Grant.yaml
Normal file
41
api/openapi/components/schemas/Grant.yaml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
type: object
|
||||
description: A long-lived grant held by one client of one user.
|
||||
required: [id, name, client, clientVersion, scopes, provider, createdAt, lastUsedAt, lastUsedIp, current]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Grant id.
|
||||
name:
|
||||
type: string
|
||||
description: Label shown to the user.
|
||||
client:
|
||||
type: string
|
||||
description: Name of the client app that holds the grant.
|
||||
clientVersion:
|
||||
type: string
|
||||
nullable: true
|
||||
description: "Version of the client app, when it sent one."
|
||||
scopes:
|
||||
type: array
|
||||
description: Scopes this grant 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.
|
||||
16
api/openapi/components/schemas/GrantCreated.yaml
Normal file
16
api/openapi/components/schemas/GrantCreated.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
type: object
|
||||
description: "Returned by every login method. The secret is shown only here; store it and never parse it."
|
||||
required: [secret, grant, user]
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
maxLength: 512
|
||||
description: "Opaque grant secret. Send it as 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
|
||||
18
api/openapi/components/schemas/GrantList.yaml
Normal file
18
api/openapi/components/schemas/GrantList.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
type: object
|
||||
description: "A page of the caller's grants."
|
||||
required: [items, total, offset, limit]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
description: "Grants on this page, by last use, most recent first; never-used grants last."
|
||||
items:
|
||||
$ref: ./Grant.yaml
|
||||
total:
|
||||
type: integer
|
||||
description: Total number of grants.
|
||||
offset:
|
||||
type: integer
|
||||
description: Zero-based index of the first returned item.
|
||||
limit:
|
||||
type: integer
|
||||
description: Maximum number of items in this page.
|
||||
8
api/openapi/components/schemas/LogoutResponse.yaml
Normal file
8
api/openapi/components/schemas/LogoutResponse.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
type: object
|
||||
description: Result of a logout.
|
||||
required: [logoutUrl]
|
||||
properties:
|
||||
logoutUrl:
|
||||
type: string
|
||||
nullable: true
|
||||
description: "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do."
|
||||
18
api/openapi/components/schemas/PasswordChangeRequest.yaml
Normal file
18
api/openapi/components/schemas/PasswordChangeRequest.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
type: object
|
||||
description: "Change the caller's own password."
|
||||
required: [currentPassword, newPassword]
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
description: The current password.
|
||||
newPassword:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
description: The new password.
|
||||
revokeOtherGrants:
|
||||
type: boolean
|
||||
default: true
|
||||
description: "Revoke every other grant of the user. The calling grant always survives. Default true."
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
5
api/openapi/components/schemas/Scope.yaml
Normal file
5
api/openapi/components/schemas/Scope.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
type: string
|
||||
description: |
|
||||
A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on
|
||||
grants and means every scope the user is entitled to, now and in future releases. New scopes may be added.
|
||||
enum: [all, read, password]
|
||||
4
api/openapi/components/schemas/ScopeRequest.yaml
Normal file
4
api/openapi/components/schemas/ScopeRequest.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
type: string
|
||||
description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working."
|
||||
pattern: '^[a-z][a-z-]*(:write)?$'
|
||||
maxLength: 64
|
||||
9
api/openapi/components/schemas/TokenRequest.yaml
Normal file
9
api/openapi/components/schemas/TokenRequest.yaml
Normal file
|
|
@ -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
|
||||
|
|
@ -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`."
|
||||
|
|
|
|||
213
api/openapi/paths/auth.yaml
Normal file
213
api/openapi/paths/auth.yaml
Normal file
|
|
@ -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
|
||||
2
go.mod
2
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
|
||||
|
|
|
|||
11
go.sum
11
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=
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
|
|||
71
server/apiv1/auth_handlers.go
Normal file
71
server/apiv1/auth_handlers.go
Normal file
|
|
@ -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
|
||||
}
|
||||
234
server/apiv1/auth_test.go
Normal file
234
server/apiv1/auth_test.go
Normal file
|
|
@ -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)) }),
|
||||
)
|
||||
})
|
||||
13
server/apiv1/db_test.go
Normal file
13
server/apiv1/db_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
var realDS model.DataStore
|
||||
|
||||
func resetDB() {
|
||||
_, _ = db.Db().Exec("delete from api_grant")
|
||||
_, _ = db.Db().Exec("delete from user")
|
||||
}
|
||||
78
server/apiv1/dto.go
Normal file
78
server/apiv1/dto.go
Normal file
|
|
@ -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
|
||||
}
|
||||
12
server/apiv1/oapi-codegen-overlay.yaml
Normal file
12
server/apiv1/oapi-codegen-overlay.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
overlay: 1.0.0
|
||||
info:
|
||||
title: Go type names for the API v1 server
|
||||
version: 1.0.0
|
||||
actions:
|
||||
# Ginkgo's dot-imported Offset would clash with a generated Offset type in this package's tests.
|
||||
- target: $.components.parameters.offset
|
||||
update:
|
||||
x-go-name: OffsetParam
|
||||
- target: $.components.parameters.limit
|
||||
update:
|
||||
x-go-name: LimitParam
|
||||
|
|
@ -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
|
||||
|
|
|
|||
47
server/apiv1/password_handlers.go
Normal file
47
server/apiv1/password_handlers.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue