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:
Deluan 2026-09-26 01:50:16 -04:00
commit 294c1a9a6a
34 changed files with 3741 additions and 33 deletions

View file

@ -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

View file

@ -3,7 +3,7 @@
"info": {
"title": "Navidrome API",
"version": "1.0.0",
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n",
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /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"
}
}
}
}

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,19 @@
type: object
description: "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

View file

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

View file

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

View file

@ -0,0 +1,41 @@
type: object
description: A long-lived grant held by one client of one user.
required: [id, name, client, clientVersion, scopes, provider, createdAt, lastUsedAt, lastUsedIp, current]
properties:
id:
type: string
description: Grant id.
name:
type: string
description: Label shown to the user.
client:
type: string
description: Name of the client app that holds the grant.
clientVersion:
type: string
nullable: true
description: "Version of the client app, when it sent one."
scopes:
type: array
description: Scopes this grant 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.

View file

@ -0,0 +1,16 @@
type: object
description: "Returned by every login method. The secret is shown only here; store it and never parse it."
required: [secret, grant, user]
properties:
secret:
type: string
maxLength: 512
description: "Opaque grant secret. Send it as 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

View file

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

View file

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

View file

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

View file

@ -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.

View file

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

View file

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

View file

@ -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

View file

@ -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
View 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
View file

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

11
go.sum
View file

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

File diff suppressed because it is too large Load diff

View file

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

View 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
View 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
View file

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

78
server/apiv1/dto.go Normal file
View 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
}

View file

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

View file

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

View file

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