refactor(api): use the grant secret as the API v1 bearer credential

API v1 no longer mints short-lived JWT access tokens. Clients send the grant
secret from POST /auth/login or /auth/setup as `Authorization: Bearer` on
every request.

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

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

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

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-09-28 21:05:57 -04:00
commit d1b876097f
37 changed files with 371 additions and 1656 deletions

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 /capabilities` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need 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",
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /capabilities` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as\n`Authorization: Bearer \u003csecret\u003e`. A revoked grant stops working within one minute at most.\n",
"license": {
"name": "GPL-3.0",
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
@ -21,7 +21,7 @@
},
{
"name": "auth",
"description": "Grants, access tokens, and login methods."
"description": "Grants and login methods."
}
],
"paths": {
@ -62,7 +62,7 @@
"server"
],
"summary": "List implemented capability modules",
"description": "The capability modules this server implements. Any valid access token may read it, whatever its scopes.",
"description": "The capability modules this server implements. Any valid grant may read it, whatever its scopes.",
"security": [
{
"bearerAuth": []
@ -122,97 +122,6 @@
}
}
},
"/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.",
"headers": {
"Cache-Control": {
"$ref": "#/components/headers/CacheControlNoStore"
}
},
"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",
"x-module": "core",
"x-stability-level": "alpha",
"tags": [
"server"
],
"security": [],
"summary": "Get the OpenAPI document (YAML)",
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
"responses": {
"200": {
"description": "The OpenAPI document.",
"headers": {
"ETag": {
"$ref": "#/components/headers/ETag"
}
},
"content": {
"application/yaml": {
"schema": {
"type": "object",
"description": "OpenAPI 3.0 document."
}
}
}
},
"304": {
"$ref": "#/components/responses/NotModified"
}
}
}
},
"/auth/grants": {
"get": {
"operationId": "listGrants",
@ -263,6 +172,40 @@
}
}
},
"/openapi.yaml": {
"get": {
"operationId": "getOpenAPISpecYAML",
"x-module": "core",
"x-stability-level": "alpha",
"tags": [
"server"
],
"security": [],
"summary": "Get the OpenAPI document (YAML)",
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
"responses": {
"200": {
"description": "The OpenAPI document.",
"headers": {
"ETag": {
"$ref": "#/components/headers/ETag"
}
},
"content": {
"application/yaml": {
"schema": {
"type": "object",
"description": "OpenAPI 3.0 document."
}
}
}
},
"304": {
"$ref": "#/components/responses/NotModified"
}
}
}
},
"/auth/grants/{id}": {
"delete": {
"operationId": "revokeGrant",
@ -273,7 +216,7 @@
"auth"
],
"summary": "Revoke one of my grants",
"description": "Revokes the grant and every token minted from it. Another user's grant id answers 404.",
"description": "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404.",
"security": [
{
"bearerAuth": []
@ -525,12 +468,7 @@
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"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`."
"description": "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`."
}
},
"schemas": {
@ -610,7 +548,6 @@
"enum": [
"validation",
"unauthorized",
"token_expired",
"forbidden",
"insufficient_scope",
"not_found",
@ -656,7 +593,7 @@
},
"Capabilities": {
"type": "object",
"description": "Capability modules this server implements, keyed by module. Keys are optional; a missing key means the\nmodule is not implemented. New modules are added as new optional keys. These are server facts, not what\nthe calling token may use.\n",
"description": "Capability modules this server implements, keyed by module. Keys are optional; a missing key means the\nmodule is not implemented. New modules are added as new optional keys. These are server facts, not what\nthe calling grant may use.\n",
"properties": {
"core": {
"$ref": "#/components/schemas/CoreCapability"
@ -692,54 +629,6 @@
}
}
},
"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.",
@ -845,7 +734,7 @@
"secret": {
"type": "string",
"maxLength": 512,
"description": "Opaque grant secret. Send it as a Bearer credential to `POST /auth/token`."
"description": "Opaque grant secret. Send it as `Authorization: Bearer \u003csecret\u003e`."
},
"grant": {
"description": "The new grant.",
@ -892,21 +781,6 @@
}
}
},
"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.",
@ -942,7 +816,7 @@
},
"scopes": {
"type": "array",
"description": "Scopes this grant may mint tokens for.",
"description": "Scopes this grant carries.",
"items": {
"$ref": "#/components/schemas/Scope"
}
@ -973,6 +847,21 @@
}
}
},
"Scope": {
"type": "string",
"description": "A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on\ngrants and means every scope the user is entitled to, now and in future releases. New scopes may be added.\n",
"enum": [
"all",
"read",
"password"
]
},
"ScopeRequest": {
"type": "string",
"description": "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working.",
"pattern": "^[a-z][a-z-]*(:write)?$",
"maxLength": 64
},
"AuthUser": {
"type": "object",
"description": "The user a grant belongs to.",
@ -1051,16 +940,6 @@
}
}
},
"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": {
@ -1086,6 +965,16 @@
}
}
},
"PayloadTooLarge": {
"description": "The request body is too large (`payload_too_large`).",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"TooManyRequests": {
"description": "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds.",
"headers": {
@ -1154,7 +1043,7 @@
}
},
"CacheControlNoStore": {
"description": "Always `no-store`, because the response carries a secret or a token.",
"description": "Always `no-store`, because the response carries a secret.",
"schema": {
"type": "string",
"enum": [

View file

@ -16,9 +16,9 @@ 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.
Operations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in
`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as
`Authorization: Bearer <secret>`. A revoked grant stops working within one minute at most.
license:
name: GPL-3.0
url: https://www.gnu.org/licenses/gpl-3.0.html
@ -28,7 +28,7 @@ tags:
- name: server
description: Server discovery and the published OpenAPI document.
- name: auth
description: Grants, access tokens, and login methods.
description: Grants and login methods.
paths:
/server:
get:
@ -57,7 +57,7 @@ paths:
x-stability-level: alpha
tags: [server]
summary: List implemented capability modules
description: The capability modules this server implements. Any valid access token may read it, whatever its scopes.
description: The capability modules this server implements. Any valid grant may read it, whatever its scopes.
security: [{bearerAuth: []}]
responses:
'200':
@ -89,62 +89,6 @@ 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.
headers:
Cache-Control:
$ref: '#/components/headers/CacheControlNoStore'
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
x-module: core
x-stability-level: alpha
tags: [server]
security: []
summary: Get the OpenAPI document (YAML)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:
'200':
description: The OpenAPI document.
headers:
ETag:
$ref: '#/components/headers/ETag'
content:
application/yaml:
schema:
type: object
description: OpenAPI 3.0 document.
'304':
$ref: '#/components/responses/NotModified'
/auth/grants:
get:
operationId: listGrants
@ -173,6 +117,28 @@ paths:
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalError'
/openapi.yaml:
get:
operationId: getOpenAPISpecYAML
x-module: core
x-stability-level: alpha
tags: [server]
security: []
summary: Get the OpenAPI document (YAML)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:
'200':
description: The OpenAPI document.
headers:
ETag:
$ref: '#/components/headers/ETag'
content:
application/yaml:
schema:
type: object
description: OpenAPI 3.0 document.
'304':
$ref: '#/components/responses/NotModified'
/auth/grants/{id}:
delete:
operationId: revokeGrant
@ -181,7 +147,7 @@ paths:
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."
description: "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404."
security: [{bearerAuth: []}]
parameters:
- name: id
@ -338,11 +304,7 @@ components:
bearerAuth:
type: http
scheme: bearer
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`."
description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`."
schemas:
ServerInfo:
type: object
@ -409,7 +371,6 @@ components:
enum:
- validation
- unauthorized
- token_expired
- forbidden
- insufficient_scope
- not_found
@ -446,7 +407,7 @@ components:
description: |
Capability modules this server implements, keyed by module. Keys are optional; a missing key means the
module is not implemented. New modules are added as new optional keys. These are server facts, not what
the calling token may use.
the calling grant may use.
properties:
core:
$ref: '#/components/schemas/CoreCapability'
@ -470,41 +431,6 @@ components:
version:
type: integer
description: Module version. Bumped only on semantic change.
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."
@ -587,7 +513,7 @@ components:
secret:
type: string
maxLength: 512
description: "Opaque grant secret. Send it as a Bearer credential to `POST /auth/token`."
description: "Opaque grant secret. Send it as `Authorization: Bearer <secret>`."
grant:
description: The new grant.
allOf:
@ -617,20 +543,6 @@ components:
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.
@ -661,7 +573,7 @@ components:
description: "Version of the client app, when it sent one."
scopes:
type: array
description: Scopes this grant may mint tokens for.
description: Scopes this grant carries.
items:
$ref: '#/components/schemas/Scope'
provider:
@ -683,6 +595,20 @@ components:
current:
type: boolean
description: True for the grant that made this request.
Scope:
type: string
description: |
A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on
grants and means every scope the user is entitled to, now and in future releases. New scopes may be added.
enum:
- all
- read
- password
ScopeRequest:
type: string
description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working."
pattern: '^[a-z][a-z-]*(:write)?$'
maxLength: 64
AuthUser:
type: object
description: The user a grant belongs to.
@ -735,12 +661,6 @@ components:
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:
@ -756,6 +676,12 @@ components:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
PayloadTooLarge:
description: "The request body is too large (`payload_too_large`)."
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
TooManyRequests:
description: "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds."
headers:
@ -803,7 +729,7 @@ components:
schema:
type: string
CacheControlNoStore:
description: Always `no-store`, because the response carries a secret or a token.
description: Always `no-store`, because the response carries a secret.
schema:
type: string
enum:

View file

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

View file

@ -1,19 +0,0 @@
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

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

View file

@ -17,7 +17,7 @@ properties:
description: "Version of the client app, when it sent one."
scopes:
type: array
description: Scopes this grant may mint tokens for.
description: Scopes this grant carries.
items:
$ref: ./Scope.yaml
provider:

View file

@ -5,7 +5,7 @@ properties:
secret:
type: string
maxLength: 512
description: "Opaque grant secret. Send it as a Bearer credential to `POST /auth/token`."
description: "Opaque grant secret. Send it as `Authorization: Bearer <secret>`."
grant:
description: The new grant.
allOf:

View file

@ -23,7 +23,6 @@ properties:
enum:
- validation
- unauthorized
- token_expired
- forbidden
- insufficient_scope
- not_found

View file

@ -1,9 +0,0 @@
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

@ -16,9 +16,9 @@ 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.
Operations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in
`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as
`Authorization: Bearer <secret>`. A revoked grant stops working within one minute at most.
license:
name: GPL-3.0
url: https://www.gnu.org/licenses/gpl-3.0.html
@ -28,7 +28,7 @@ tags:
- name: server
description: Server discovery and the published OpenAPI document.
- name: auth
description: Grants, access tokens, and login methods.
description: Grants and login methods.
paths:
/server:
$ref: ./paths/server.yaml
@ -38,8 +38,6 @@ 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}:
@ -57,8 +55,4 @@ components:
bearerAuth:
type: http
scheme: bearer
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`."
description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`."

View file

@ -1,37 +1,3 @@
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.
headers:
Cache-Control:
$ref: ../components/headers/CacheControlNoStore.yaml
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
@ -68,7 +34,7 @@ grant:
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."
description: "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404."
security: [{bearerAuth: []}]
parameters:
- name: id

View file

@ -4,7 +4,7 @@ get:
x-stability-level: alpha
tags: [server]
summary: List implemented capability modules
description: The capability modules this server implements. Any valid access token may read it, whatever its scopes.
description: The capability modules this server implements. Any valid grant may read it, whatever its scopes.
security: [{bearerAuth: []}]
responses:
'200':

View file

@ -32,7 +32,6 @@ const (
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
JWTSecretKey = "JWTSecret"
JWTPublicSecretKey = "JWTPublicSecret"
JWTAPIv1SecretKey = "JWTAPIv1Secret"
JWTIssuer = "ND"
DefaultSessionTimeout = 48 * time.Hour
APIv1GrantIdleExpiry = 90 * 24 * time.Hour

View file

@ -1,94 +0,0 @@
package apiauth
import (
"maps"
"sync"
"time"
)
const maxLivenessEntries = 1024
type livenessEntry struct {
userID string
epoch int
lastUsedAt time.Time
expires time.Time
}
// livenessCache bounds how long a node trusts "this grant exists" without asking the DB.
type livenessCache struct {
mu sync.Mutex
ttl time.Duration
gen uint64
entries map[string]livenessEntry
evicted map[string]uint64 // grant id -> generation of its last eviction
floor uint64 // fills started before the last trim of evicted are dropped
}
func newLivenessCache(ttl time.Duration) *livenessCache {
return &livenessCache{ttl: ttl, entries: map[string]livenessEntry{}, evicted: map[string]uint64{}}
}
func (c *livenessCache) begin() uint64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.gen
}
func (c *livenessCache) get(id string, now time.Time) (livenessEntry, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[id]
if !ok || !now.Before(e.expires) {
return livenessEntry{}, false
}
return e, true
}
// put ignores a fill whose DB read started before the grant was last evicted.
func (c *livenessCache) put(id string, e livenessEntry, now time.Time, started uint64) {
c.mu.Lock()
defer c.mu.Unlock()
if started < c.floor || c.evicted[id] > started {
return
}
if len(c.entries) >= maxLivenessEntries {
maps.DeleteFunc(c.entries, func(_ string, v livenessEntry) bool { return !now.Before(v.expires) })
}
if _, refresh := c.entries[id]; !refresh && len(c.entries) >= maxLivenessEntries {
// Dropping an arbitrary live entry only costs that grant one extra DB read.
for k := range c.entries {
delete(c.entries, k)
break
}
}
e.expires = now.Add(c.ttl)
c.entries[id] = e
}
func (c *livenessCache) evict(id string) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.evicted) >= maxLivenessEntries {
clear(c.evicted)
c.floor = c.gen
}
c.gen++
c.evicted[id] = c.gen
delete(c.entries, id)
}
func (c *livenessCache) markUsed(id string, at time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
if e, ok := c.entries[id]; ok {
e.lastUsedAt = at
c.entries[id] = e
}
}
func (c *livenessCache) len() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.entries)
}

View file

@ -1,113 +0,0 @@
package apiauth
import (
"fmt"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("livenessCache", func() {
var c *livenessCache
var t0 time.Time
BeforeEach(func() {
c = newLivenessCache(30 * time.Second)
t0 = time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
})
It("returns an entry until its TTL passes", func() {
c.put("g1", livenessEntry{userID: "u1", epoch: 2}, t0, c.begin())
e, ok := c.get("g1", t0.Add(29*time.Second))
Expect(ok).To(BeTrue())
Expect(e.userID).To(Equal("u1"))
Expect(e.epoch).To(Equal(2))
_, ok = c.get("g1", t0.Add(30*time.Second))
Expect(ok).To(BeFalse())
})
It("forgets evicted entries", func() {
c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin())
c.evict("g1")
_, ok := c.get("g1", t0)
Expect(ok).To(BeFalse())
})
It("drops a fill that started before an eviction of the same grant", func() {
started := c.begin() // a request reads the grant from the DB...
c.evict("g1") // ...a logout deletes and evicts it...
c.put("g1", livenessEntry{userID: "u1"}, t0, started) // ...then the slow request tries to cache it
_, ok := c.get("g1", t0)
Expect(ok).To(BeFalse())
})
It("still accepts fills of other grants and later fills of the same grant", func() {
started := c.begin()
c.evict("g1")
c.put("g2", livenessEntry{userID: "u2"}, t0, started)
_, ok := c.get("g2", t0)
Expect(ok).To(BeTrue())
c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin())
_, ok = c.get("g1", t0)
Expect(ok).To(BeTrue())
})
It("still drops a racing fill after the eviction log is trimmed", func() {
started := c.begin()
c.evict("g1")
for i := range maxLivenessEntries {
c.evict(fmt.Sprint("other", i))
}
c.put("fresh", livenessEntry{}, t0, c.begin())
c.put("g1", livenessEntry{userID: "u1"}, t0, started)
_, ok := c.get("g1", t0)
Expect(ok).To(BeFalse())
})
It("stays bounded under eviction-only traffic and still drops a fill started before the trim", func() {
started := c.begin()
c.evict("g1")
for i := range 3 * maxLivenessEntries {
c.evict(fmt.Sprint("other", i))
}
Expect(len(c.evicted)).To(BeNumerically("<=", maxLivenessEntries))
c.put("g1", livenessEntry{userID: "u1"}, t0, started)
_, ok := c.get("g1", t0)
Expect(ok).To(BeFalse())
c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin())
_, ok = c.get("g1", t0)
Expect(ok).To(BeTrue())
})
It("records the last use without extending the TTL", func() {
c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin())
c.markUsed("g1", t0.Add(10*time.Second))
e, _ := c.get("g1", t0.Add(11*time.Second))
Expect(e.lastUsedAt).To(Equal(t0.Add(10 * time.Second)))
_, ok := c.get("g1", t0.Add(30*time.Second))
Expect(ok).To(BeFalse())
})
It("drops expired entries when it grows", func() {
for i := range maxLivenessEntries {
c.put(fmt.Sprint(i), livenessEntry{}, t0, c.begin())
}
c.put("fresh", livenessEntry{}, t0.Add(time.Minute), c.begin())
Expect(c.len()).To(Equal(1))
})
It("never grows past its cap, even when every entry is live", func() {
for i := range maxLivenessEntries {
c.put(fmt.Sprint(i), livenessEntry{}, t0, c.begin())
}
c.put("fresh", livenessEntry{}, t0, c.begin())
Expect(c.len()).To(Equal(maxLivenessEntries))
_, ok := c.get("fresh", t0)
Expect(ok).To(BeTrue())
c.put("fresh", livenessEntry{userID: "u1"}, t0, c.begin())
Expect(c.len()).To(Equal(maxLivenessEntries))
})
})

View file

@ -33,19 +33,11 @@ func createUser(ctx context.Context, password string, admin bool) model.User {
return *stored
}
// login runs the full client flow for a user whose password is "pw": grant, resolve, then mint.
func login(ctx context.Context, svc *Service, u model.User) (*Issued, *Principal, *AccessToken) {
// login signs in a user whose password is "pw" and authenticates with the new grant secret.
func login(ctx context.Context, svc *Service, u model.User) (*Issued, *Principal) {
issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
p, err := svc.ResolveGrant(ctx, issued.Secret, "")
p, err := svc.Authenticate(ctx, issued.Secret, "")
ExpectWithOffset(1, err).ToNot(HaveOccurred())
tok, err := svc.Mint(ctx, p, nil)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return issued, p, tok
}
func mustMint(ctx context.Context, svc *Service, p *Principal) string {
tok, err := svc.Mint(ctx, p, nil)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return tok.Token
return issued, p
}

View file

@ -43,7 +43,7 @@ func Entitled(requested []string, isAdmin bool) []string {
return normalize(out)
}
// Expand turns a grant's stored scopes into the concrete scopes a token may carry right now.
// Expand turns a grant's stored scopes into the concrete scopes it carries right now.
func Expand(granted []string, isAdmin bool) []string {
var out []string
for _, s := range granted {
@ -62,20 +62,6 @@ func Allowed(scopes []string, isAdmin bool) []string {
return normalize(out)
}
// Attenuate returns the requested subset of available; a nil request means "everything available".
func Attenuate(available, requested []string) []string {
if requested == nil {
return normalize(available)
}
out := []string{}
for _, s := range requested {
if Satisfies(available, s) {
out = append(out, s)
}
}
return normalize(out)
}
func Satisfies(scopes []string, required string) bool {
return slices.Contains(scopes, required) ||
(!strings.HasSuffix(required, ":write") && slices.Contains(scopes, required+":write"))

View file

@ -49,22 +49,6 @@ var _ = Describe("scopes", func() {
})
})
Describe("Attenuate", func() {
available := []string{"playlists:write", "read"}
It("returns everything when no subset is asked", func() {
Expect(Attenuate(available, nil)).To(Equal([]string{"playlists:write", "read"}))
})
It("returns nothing for an explicit empty request", func() {
Expect(Attenuate(available, []string{})).To(BeEmpty())
})
It("returns the overlap and drops unknown scopes", func() {
Expect(Attenuate(available, []string{"read", "sync"})).To(Equal([]string{"read"}))
})
It("grants the base scope when only its :write form is available", func() {
Expect(Attenuate(available, []string{"playlists"})).To(Equal([]string{"playlists"}))
})
})
Describe("Satisfies", func() {
It("accepts the exact scope or its :write form", func() {
Expect(Satisfies([]string{"read"}, "read")).To(BeTrue())

View file

@ -5,9 +5,6 @@ import (
"context"
"errors"
"fmt"
"slices"
"sync"
"sync/atomic"
"time"
"github.com/navidrome/navidrome/conf"
@ -19,9 +16,7 @@ import (
)
const (
TokenTTL = time.Hour
IdleExpiry = consts.APIv1GrantIdleExpiry
cacheTTL = 30 * time.Second
touchInterval = 5 * time.Minute
)
@ -43,12 +38,6 @@ type Issued struct {
User model.User
}
type AccessToken struct {
Token string
ExpiresIn time.Duration
Scopes []string
}
type Principal struct {
User model.User
GrantID string
@ -58,10 +47,7 @@ type Principal struct {
type Service struct {
ds model.DataStore
checkers func(ds model.DataStore) []CredentialChecker // per datastore, so password change can check inside its transaction
cache *livenessCache
now func() time.Time
signerMu sync.Mutex
sg atomic.Pointer[signer]
}
func New(ds model.DataStore) *Service {
@ -70,30 +56,11 @@ func New(ds model.DataStore) *Service {
checkers: func(ds model.DataStore) []CredentialChecker {
return []CredentialChecker{dbChecker{ds: ds}}
},
cache: newLivenessCache(cacheTTL),
now: time.Now,
now: time.Now,
}
return s
}
// signer loads the key on first use, so building the router never touches the database; only a success is kept.
func (s *Service) signer() (*signer, error) {
if sg := s.sg.Load(); sg != nil {
return sg, nil
}
s.signerMu.Lock()
defer s.signerMu.Unlock()
if sg := s.sg.Load(); sg != nil {
return sg, nil
}
sg, err := loadSigner(context.Background(), s.ds, func() time.Time { return s.now() })
if err != nil {
return nil, err
}
s.sg.Store(sg)
return sg, nil
}
func PasswordChangeable(u model.User) bool {
return u.IsAdmin || conf.Server.EnableUserEditing
}
@ -147,7 +114,7 @@ func (s *Service) issue(ctx context.Context, ds model.DataStore, u model.User, p
return &Issued{Secret: secret, Grant: g, User: u}, nil
}
func (s *Service) ResolveGrant(ctx context.Context, secret, ip string) (*Principal, error) {
func (s *Service) Authenticate(ctx context.Context, secret, ip string) (*Principal, error) {
g, err := s.ds.Grant().FindBySecretHash(ctx, hashSecret(secret))
if errors.Is(err, model.ErrNotFound) {
return nil, model.ErrInvalidAuth
@ -172,20 +139,6 @@ func (s *Service) ResolveGrant(ctx context.Context, secret, ip string) (*Princip
return &Principal{User: *u, GrantID: g.ID, Scopes: Expand(g.Scopes, u.IsAdmin)}, nil
}
func (s *Service) Mint(ctx context.Context, p *Principal, requested []string) (*AccessToken, error) {
sg, err := s.signer()
if err != nil {
return nil, err
}
now := s.now()
scopes := Attenuate(p.Scopes, requested)
tok, err := sg.sign(claims{UserID: p.User.ID, GrantID: p.GrantID, Scopes: scopes, IssuedAt: now, ExpiresAt: now.Add(TokenTTL)})
if err != nil {
return nil, fmt.Errorf("signing access token: %w", err)
}
return &AccessToken{Token: tok, ExpiresIn: TokenTTL, Scopes: scopes}, nil
}
func (s *Service) loadUser(ctx context.Context, userID string) (*model.User, error) {
u, err := s.ds.User().Get(ctx, userID)
if errors.Is(err, model.ErrNotFound) {
@ -194,13 +147,11 @@ func (s *Service) loadUser(ctx context.Context, userID string) (*model.User, err
return u, err
}
// dropIdle deletes only still-idle grants, sparing one renewed meanwhile, and evicts after deleting so a
// concurrent fill cannot re-cache the dead grant.
// dropIdle deletes only still-idle grants, sparing one renewed meanwhile.
func (s *Service) dropIdle(ctx context.Context, id string, idleSince time.Time) {
if _, err := s.ds.Grant().DeleteIdle(ctx, idleSince); err != nil {
log.Warn(ctx, "API v1: could not delete idle grants", "grant", id, err)
}
s.cache.evict(id)
}
// settleEpoch re-reads grant and user in one read transaction: separate reads can straddle a password change
@ -217,14 +168,12 @@ func (s *Service) settleEpoch(ctx context.Context, grantID string) (*model.Grant
return err
})
if errors.Is(err, model.ErrNotFound) {
s.cache.evict(grantID)
return nil, nil, model.ErrInvalidAuth
}
if err != nil {
return nil, nil, err
}
if g.UserEpoch != u.TokenEpoch {
s.cache.evict(grantID)
if err := s.ds.Grant().DeleteStaleEpochs(ctx, u.ID, u.TokenEpoch); err != nil {
log.Warn(ctx, "API v1: could not delete the user's grants from older epochs", "user", u.ID, "grant", grantID, err)
}
@ -241,61 +190,7 @@ func (s *Service) touch(ctx context.Context, id, ip string, lastUsed time.Time)
}
if err := s.ds.Grant().Touch(ctx, id, ip, now, now.Add(-touchInterval)); err != nil {
log.Warn(ctx, "API v1: could not record grant use", "grant", id, err)
return
}
s.cache.markUsed(id, now)
}
func (s *Service) Authenticate(ctx context.Context, token, ip string) (*Principal, error) {
sg, err := s.signer()
if err != nil {
return nil, err
}
c, err := sg.parse(token)
if err != nil {
return nil, err
}
u, err := s.loadUser(ctx, c.UserID)
if err != nil {
return nil, err
}
entry, u, err := s.liveGrant(ctx, c.GrantID, u)
if err != nil {
return nil, err
}
if entry.userID != c.UserID {
return nil, model.ErrInvalidAuth
}
if slices.Contains(c.Scopes, ScopeAdmin) && !u.IsAdmin {
return nil, ErrInsufficientScope
}
s.touch(ctx, c.GrantID, ip, entry.lastUsedAt)
return &Principal{User: *u, GrantID: c.GrantID, Scopes: Allowed(c.Scopes, u.IsAdmin)}, nil
}
// liveGrant trusts the cache only while its epoch matches; a mismatch is settled from one consistent read.
func (s *Service) liveGrant(ctx context.Context, id string, u *model.User) (livenessEntry, *model.User, error) {
now := s.now()
if e, ok := s.cache.get(id, now); ok && e.epoch == u.TokenEpoch {
return e, u, nil
}
started := s.cache.begin()
g, err := s.ds.Grant().Get(ctx, id)
if errors.Is(err, model.ErrNotFound) {
s.cache.evict(id)
return livenessEntry{}, nil, model.ErrInvalidAuth
}
if err != nil {
return livenessEntry{}, nil, err
}
if g.UserEpoch != u.TokenEpoch {
if g, u, err = s.settleEpoch(ctx, id); err != nil {
return livenessEntry{}, nil, err
}
}
e := livenessEntry{userID: g.UserID, epoch: g.UserEpoch, lastUsedAt: gg.V(g.LastUsedAt)}
s.cache.put(id, e, now, started)
return e, u, nil
}
// ListGrants shows only the current epoch: grants left on an older one are dead but only deleted when presented.
@ -310,18 +205,13 @@ func (s *Service) ListGrants(ctx context.Context, p *Principal, offset, limit in
}
func (s *Service) RevokeGrant(ctx context.Context, p *Principal, grantID string) error {
if err := s.ds.Grant().DeleteForUser(ctx, p.User.ID, grantID); err != nil {
return err
}
s.cache.evict(grantID)
return nil
return s.ds.Grant().DeleteForUser(ctx, p.User.ID, grantID)
}
// Logout succeeds when the grant is already gone, e.g. revoked by another node or a concurrent logout.
func (s *Service) Logout(ctx context.Context, p *Principal) error {
err := s.RevokeGrant(ctx, p, p.GrantID)
if errors.Is(err, model.ErrNotFound) {
s.cache.evict(p.GrantID)
return nil
}
return err

View file

@ -27,142 +27,82 @@ var _ = Describe("Service: sessions", func() {
})
Describe("Authenticate", func() {
It("returns the token's scopes and marks the grant used", func() {
It("rejects the secret at once after logout", func() {
u := createUser(ctx, "pw", false)
issued, _, tok := login(ctx, svc, u)
now = now.Add(10 * time.Minute)
p, err := svc.Authenticate(ctx, tok.Token, "10.1.1.1")
Expect(err).ToNot(HaveOccurred())
Expect(p.GrantID).To(Equal(issued.Grant.ID))
Expect(p.Scopes).To(Equal(tok.Scopes))
g, _ := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(g.LastUsedIP).To(Equal("10.1.1.1"))
})
It("reports an expired token as ErrTokenExpired", func() {
u := createUser(ctx, "pw", false)
_, _, tok := login(ctx, svc, u)
now = now.Add(TokenTTL + clockSkew + time.Second)
_, err := svc.Authenticate(ctx, tok.Token, "")
Expect(err).To(MatchError(ErrTokenExpired))
})
It("rejects a token at once on the node that revoked its grant", func() {
u := createUser(ctx, "pw", false)
_, p, tok := login(ctx, svc, u)
_, err := svc.Authenticate(ctx, tok.Token, "")
Expect(err).ToNot(HaveOccurred())
issued, p := login(ctx, svc, u)
Expect(svc.Logout(ctx, p)).To(Succeed())
_, err = svc.Authenticate(ctx, tok.Token, "")
_, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("stops a token revoked on another node within the cache time", func() {
It("rejects the secret at once when another node revoked its grant", func() {
u := createUser(ctx, "pw", false)
issued, _, tok := login(ctx, svc, u)
_, err := svc.Authenticate(ctx, tok.Token, "")
Expect(err).ToNot(HaveOccurred())
Expect(realDS.Grant().DeleteForUser(ctx, u.ID, issued.Grant.ID)).To(Succeed()) // another node
_, err = svc.Authenticate(ctx, tok.Token, "")
Expect(err).ToNot(HaveOccurred()) // still cached
now = now.Add(cacheTTL)
_, err = svc.Authenticate(ctx, tok.Token, "")
issued, _ := login(ctx, svc, u)
Expect(realDS.Grant().DeleteForUser(ctx, u.ID, issued.Grant.ID)).To(Succeed())
_, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("kills grants when the password changes anywhere else", func() {
u := createUser(ctx, "pw", false)
_, _, tok := login(ctx, svc, u)
issued, _ := login(ctx, svc, u)
u.NewPassword = "reset-by-admin"
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
_, err := svc.Authenticate(ctx, tok.Token, "")
_, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("does not kill a grant kept by a password change made through another node", func() {
u := createUser(ctx, "pw", false)
_, p, tok := login(ctx, svc, u)
_, err := svc.Authenticate(ctx, tok.Token, "") // caches the old epoch
Expect(err).ToNot(HaveOccurred())
issued, p := login(ctx, svc, u)
other := New(realDS) // another node
other.SetClock(func() time.Time { return now })
Expect(other.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed())
_, err = svc.Authenticate(ctx, tok.Token, "")
_, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).ToNot(HaveOccurred())
})
It("rejects a token carrying admin once the user is no longer an admin", func() {
It("does not delete a kept grant when the password changed between reading the grant and the user", func() {
u := createUser(ctx, "pw", false)
issued, p := login(ctx, svc, u)
racing := New(afterFindDS{DataStore: realDS, after: func() {
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed())
}})
racing.SetClock(func() time.Time { return now })
_, err := racing.Authenticate(ctx, issued.Secret, "")
Expect(err).ToNot(HaveOccurred())
_, err = realDS.Grant().Get(ctx, p.GrantID)
Expect(err).ToNot(HaveOccurred())
})
It("drops admin from a grant once its user is no longer an admin", func() {
saved := KnownScopes
KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin}
DeferCleanup(func() { KnownScopes = saved })
u := createUser(ctx, "pw", true)
_, p, tok := login(ctx, svc, u)
Expect(tok.Scopes).To(ContainElement(ScopeAdmin))
issued, p := login(ctx, svc, u)
Expect(p.Scopes).To(ContainElement(ScopeAdmin))
u.IsAdmin = false
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
_, err := svc.Authenticate(ctx, tok.Token, "")
Expect(err).To(MatchError(ErrInsufficientScope))
fresh, err := svc.Mint(ctx, &Principal{User: u, GrantID: p.GrantID, Scopes: Expand(model.Scopes{ScopeAll}, false)}, nil)
demoted, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).ToNot(HaveOccurred())
Expect(fresh.Scopes).ToNot(ContainElement(ScopeAdmin))
Expect(demoted.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))
})
It("rejects a live token after its user is deleted, and the grant row is gone", func() {
It("rejects the secret after its user is deleted, and the grant row is gone", func() {
u := createUser(ctx, "pw", false)
issued, _, tok := login(ctx, svc, u)
issued, _ := login(ctx, svc, u)
Expect(realDS.User().Delete(request.WithUser(ctx, model.User{IsAdmin: true}), u.ID)).To(Succeed())
_, err := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).To(MatchError(model.ErrNotFound))
now = now.Add(cacheTTL)
_, err = svc.Authenticate(ctx, tok.Token, "")
_, err = svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("grants no scopes to a signed token claiming all", func() {
u := createUser(ctx, "pw", true)
_, p, _ := login(ctx, svc, u)
sg, err := svc.signer()
Expect(err).ToNot(HaveOccurred())
tok, err := sg.sign(claims{UserID: u.ID, GrantID: p.GrantID, Scopes: []string{ScopeAll, "unknown"}, IssuedAt: now, ExpiresAt: now.Add(TokenTTL)})
Expect(err).ToNot(HaveOccurred())
got, err := svc.Authenticate(ctx, tok, "")
Expect(err).ToNot(HaveOccurred())
Expect(got.Scopes).To(BeEmpty())
})
It("rejects a token whose grant belongs to another user, even across an epoch change", func() {
alice := createUser(ctx, "pw", false)
bob := createUser(ctx, "pw", false)
bobGrant, _, _ := login(ctx, svc, bob)
alice.NewPassword = "bumped"
Expect(realDS.User().Put(ctx, &alice)).To(Succeed())
sg, err := svc.signer()
Expect(err).ToNot(HaveOccurred())
tok, err := sg.sign(claims{UserID: alice.ID, GrantID: bobGrant.Grant.ID, Scopes: []string{ScopeRead}, IssuedAt: now, ExpiresAt: now.Add(TokenTTL)})
Expect(err).ToNot(HaveOccurred())
_, err = svc.Authenticate(ctx, tok, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("does not delete a kept grant when the user was read before a password change", func() {
u := createUser(ctx, "pw", false)
_, p, _ := login(ctx, svc, u)
stale, err := realDS.User().Get(ctx, u.ID) // read before the change lands
Expect(err).ToNot(HaveOccurred())
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed())
_, _, err = svc.liveGrant(ctx, p.GrantID, stale)
Expect(err).ToNot(HaveOccurred())
_, err = realDS.Grant().Get(ctx, p.GrantID)
Expect(err).ToNot(HaveOccurred())
})
It("leaves a login that raced a password change with a dead grant", func() {
u := createUser(ctx, "pw", false)
reached, release := make(chan struct{}), make(chan struct{})
@ -184,7 +124,7 @@ var _ = Describe("Service: sessions", func() {
<-done
Expect(loginErr).ToNot(HaveOccurred())
_, err := New(realDS).ResolveGrant(ctx, issued.Secret, "")
_, err := New(realDS).Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
@ -192,8 +132,8 @@ var _ = Describe("Service: sessions", func() {
Describe("grant management", func() {
It("lists the user's grants and marks the current one", func() {
u := createUser(ctx, "pw", false)
first, _, _ := login(ctx, svc, u)
_, p, _ := login(ctx, svc, u)
first, _ := login(ctx, svc, u)
_, p := login(ctx, svc, u)
grants, total, err := svc.ListGrants(ctx, p, 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(total).To(Equal(int64(2)))
@ -208,7 +148,7 @@ var _ = Describe("Service: sessions", func() {
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
issued, err := svc.Login(ctx, u.UserName, "reset-by-admin", meta, nil)
Expect(err).ToNot(HaveOccurred())
p, err := svc.ResolveGrant(ctx, issued.Secret, "")
p, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).ToNot(HaveOccurred())
grants, total, err := svc.ListGrants(ctx, p, 0, 10)
@ -220,36 +160,44 @@ var _ = Describe("Service: sessions", func() {
It("logs out successfully when the grant is already gone", func() {
u := createUser(ctx, "pw", false)
_, p, tok := login(ctx, svc, u)
_, err := svc.Authenticate(ctx, tok.Token, "")
Expect(err).ToNot(HaveOccurred())
issued, p := login(ctx, svc, u)
Expect(realDS.Grant().DeleteForUser(ctx, u.ID, p.GrantID)).To(Succeed()) // another node
Expect(svc.Logout(ctx, p)).To(Succeed())
_, err = svc.Authenticate(ctx, tok.Token, "")
_, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("refuses to revoke another user's grant", func() {
alice := createUser(ctx, "pw", false)
bob := createUser(ctx, "pw", false)
aliceGrant, _, _ := login(ctx, svc, alice)
_, bobP, _ := login(ctx, svc, bob)
aliceGrant, _ := login(ctx, svc, alice)
_, bobP := login(ctx, svc, bob)
Expect(svc.RevokeGrant(ctx, bobP, aliceGrant.Grant.ID)).To(MatchError(model.ErrNotFound))
_, err := svc.Authenticate(ctx, aliceGrant.Secret, "")
Expect(err).ToNot(HaveOccurred())
})
It("rejects the secret at once after its grant is revoked", func() {
u := createUser(ctx, "pw", false)
other, _ := login(ctx, svc, u)
_, p := login(ctx, svc, u)
Expect(svc.RevokeGrant(ctx, p, other.Grant.ID)).To(Succeed())
_, err := svc.Authenticate(ctx, other.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Describe("ChangePassword", func() {
It("revokes other grants by default and keeps the caller's", func() {
u := createUser(ctx, "pw", false)
_, _, otherTok := login(ctx, svc, u)
_, p, myTok := login(ctx, svc, u)
other, _ := login(ctx, svc, u)
mine, p := login(ctx, svc, u)
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)).To(Succeed())
_, err := svc.Authenticate(ctx, myTok.Token, "")
_, err := svc.Authenticate(ctx, mine.Secret, "")
Expect(err).ToNot(HaveOccurred())
now = now.Add(cacheTTL)
_, err = svc.Authenticate(ctx, otherTok.Token, "")
_, err = svc.Authenticate(ctx, other.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = svc.Login(ctx, u.UserName, "pw2", meta, nil)
@ -258,17 +206,16 @@ var _ = Describe("Service: sessions", func() {
It("keeps every grant when revokeOthers is false", func() {
u := createUser(ctx, "pw", false)
_, _, otherTok := login(ctx, svc, u)
_, p, _ := login(ctx, svc, u)
other, _ := login(ctx, svc, u)
_, p := login(ctx, svc, u)
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed())
now = now.Add(cacheTTL)
_, err := svc.Authenticate(ctx, otherTok.Token, "")
_, err := svc.Authenticate(ctx, other.Secret, "")
Expect(err).ToNot(HaveOccurred())
})
It("rejects a wrong current password without changing anything", func() {
u := createUser(ctx, "pw", false)
_, p, _ := login(ctx, svc, u)
_, p := login(ctx, svc, u)
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "wrong", "pw2", true)
Expect(err).To(MatchError(ErrCurrentPasswordMismatch))
_, err = svc.Login(ctx, u.UserName, "pw", meta, nil)
@ -278,30 +225,30 @@ var _ = Describe("Service: sessions", func() {
It("is forbidden for non-admins when user editing is off", func() {
conf.Server.EnableUserEditing = false
u := createUser(ctx, "pw", false)
_, p, _ := login(ctx, svc, u)
_, p := login(ctx, svc, u)
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("does not revive grants killed by an earlier reset when keeping grants", func() {
u := createUser(ctx, "pw", false)
killed, _, _ := login(ctx, svc, u)
killed, _ := login(ctx, svc, u)
u.NewPassword = "reset-by-admin" // old-UI reset: the killed grant stays on the old epoch until presented
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
issued2, err := svc.Login(ctx, u.UserName, "reset-by-admin", meta, nil)
Expect(err).ToNot(HaveOccurred())
p2, err := svc.ResolveGrant(ctx, issued2.Secret, "")
p2, err := svc.Authenticate(ctx, issued2.Secret, "")
Expect(err).ToNot(HaveOccurred())
Expect(svc.ChangePassword(request.WithUser(ctx, p2.User), p2, "reset-by-admin", "pw3", false)).To(Succeed())
_, err = svc.ResolveGrant(ctx, killed.Secret, "")
_, err = svc.Authenticate(ctx, killed.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("rejects a caller whose grant was revoked before the change ran", func() {
u := createUser(ctx, "pw", false)
_, p, _ := login(ctx, svc, u)
_, p := login(ctx, svc, u)
Expect(realDS.Grant().DeleteForUser(ctx, u.ID, p.GrantID)).To(Succeed())
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)
Expect(err).To(MatchError(model.ErrInvalidAuth))
@ -309,9 +256,19 @@ var _ = Describe("Service: sessions", func() {
Expect(err).ToNot(HaveOccurred())
})
It("rejects a caller naming another user's grant", func() {
alice := createUser(ctx, "pw", false)
bob := createUser(ctx, "pw", false)
_, aliceP := login(ctx, svc, alice)
bobGrant, _ := login(ctx, svc, bob)
forged := &Principal{User: aliceP.User, GrantID: bobGrant.Grant.ID}
err := svc.ChangePassword(request.WithUser(ctx, alice), forged, "pw", "pw2", true)
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("rolls back the password and epoch when a grant update fails", func() {
u := createUser(ctx, "pw", false)
_, p, _ := login(ctx, svc, u)
issued, p := login(ctx, svc, u)
failing := New(failingEpochDS{realDS})
failing.SetClock(func() time.Time { return now })
@ -322,12 +279,33 @@ var _ = Describe("Service: sessions", func() {
Expect(reloaded.TokenEpoch).To(Equal(u.TokenEpoch))
_, err = svc.Login(ctx, u.UserName, "pw", meta, nil)
Expect(err).ToNot(HaveOccurred())
_, err = svc.Authenticate(ctx, mustMint(ctx, svc, p), "")
_, err = svc.Authenticate(ctx, issued.Secret, "")
Expect(err).ToNot(HaveOccurred())
})
})
})
// afterFindDS calls after between finding the grant by its secret and reading its user.
type afterFindDS struct {
model.DataStore
after func()
}
func (d afterFindDS) Grant() model.GrantRepository {
return afterFindGrants{GrantRepository: d.DataStore.Grant(), after: d.after}
}
type afterFindGrants struct {
model.GrantRepository
after func()
}
func (g afterFindGrants) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) {
found, err := g.GrantRepository.FindBySecretHash(ctx, hash)
g.after()
return found, err
}
type pausingChecker struct {
inner CredentialChecker
reached, release chan struct{}

View file

@ -2,7 +2,6 @@ package apiauth
import (
"context"
"errors"
"strings"
"time"
@ -14,30 +13,6 @@ import (
. "github.com/onsi/gomega"
)
var errFlakyProps = errors.New("database is locked")
type flakyPropsDS struct {
model.DataStore
failures int
}
func (d *flakyPropsDS) Property() model.PropertyRepository {
return &flakyProps{PropertyRepository: d.DataStore.Property(), ds: d}
}
type flakyProps struct {
model.PropertyRepository
ds *flakyPropsDS
}
func (p *flakyProps) Get(ctx context.Context, id string) (string, error) {
if p.ds.failures > 0 {
p.ds.failures--
return "", errFlakyProps
}
return p.PropertyRepository.Get(ctx, id)
}
// renewingDS runs renew right before DeleteIdle, as a node resolving the grant meanwhile would.
type renewingDS struct {
model.DataStore
@ -60,7 +35,7 @@ func (g renewingGrants) DeleteIdle(ctx context.Context, idleSince time.Time) (in
var meta = ClientMeta{Name: "Living room", Client: "TestApp", ClientVersion: "1.0"}
var _ = Describe("Service: grants and tokens", func() {
var _ = Describe("Service: grants", func() {
var ctx context.Context
var svc *Service
var now time.Time
@ -128,59 +103,59 @@ var _ = Describe("Service: grants and tokens", func() {
// The empty-database path is covered end to end in server/apiv1, which owns a fresh DB.
})
Describe("ResolveGrant and Mint", func() {
It("mints a token with the grant's expanded scopes and a 1h lifetime", func() {
u := createUser(ctx, "pw", false)
issued, p, tok := login(ctx, svc, u)
Expect(p.GrantID).To(Equal(issued.Grant.ID))
Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))
Expect(tok.ExpiresIn).To(Equal(time.Hour))
Expect(tok.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))
principal, err := svc.Authenticate(ctx, tok.Token, "10.0.0.9")
Expect(err).ToNot(HaveOccurred())
Expect(principal.User.ID).To(Equal(u.ID))
})
It("retries loading the signing key after a failed load", func() {
flaky := &flakyPropsDS{DataStore: realDS, failures: 1}
svc = New(flaky)
svc.SetClock(func() time.Time { return now })
Describe("Authenticate", func() {
It("resolves the secret to its user and the grant's expanded scopes", func() {
u := createUser(ctx, "pw", false)
issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil)
Expect(err).ToNot(HaveOccurred())
p, err := svc.ResolveGrant(ctx, issued.Secret, "")
p, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.9")
Expect(err).ToNot(HaveOccurred())
_, err = svc.Mint(ctx, p, nil)
Expect(err).To(MatchError(errFlakyProps))
tok, err := svc.Mint(ctx, p, nil)
Expect(err).ToNot(HaveOccurred())
Expect(tok.Token).ToNot(BeEmpty())
Expect(p.User.ID).To(Equal(u.ID))
Expect(p.GrantID).To(Equal(issued.Grant.ID))
Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))
})
It("attenuates to the requested subset", func() {
It("carries only the scopes stored on a narrow grant", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
p, _ := svc.ResolveGrant(ctx, issued.Secret, "")
tok, err := svc.Mint(ctx, p, []string{"read", "sync"})
issued, err := svc.Login(ctx, u.UserName, "pw", meta, []string{ScopePassword})
Expect(err).ToNot(HaveOccurred())
Expect(tok.Scopes).To(Equal([]string{ScopeRead}))
p, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).ToNot(HaveOccurred())
Expect(p.Scopes).To(Equal([]string{ScopePassword}))
})
It("counts minting as use", func() {
It("records the first use with the client IP", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
p, _ := svc.ResolveGrant(ctx, issued.Secret, "10.0.0.9")
_, err := svc.Mint(ctx, p, nil)
_, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.9")
Expect(err).ToNot(HaveOccurred())
g, _ := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(g.LastUsedAt).ToNot(BeNil())
Expect(g.LastUsedIP).To(Equal("10.0.0.9"))
})
It("records use again only after the touch interval", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
_, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.1")
Expect(err).ToNot(HaveOccurred())
now = now.Add(touchInterval - time.Second)
_, err = svc.Authenticate(ctx, issued.Secret, "10.0.0.2")
Expect(err).ToNot(HaveOccurred())
g, _ := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(g.LastUsedIP).To(Equal("10.0.0.1"))
now = now.Add(2 * time.Second)
_, err = svc.Authenticate(ctx, issued.Secret, "10.0.0.3")
Expect(err).ToNot(HaveOccurred())
g, _ = realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(g.LastUsedIP).To(Equal("10.0.0.3"))
Expect(g.LastUsedAt.Equal(now)).To(BeTrue())
})
It("rejects unknown secrets", func() {
_, err := svc.ResolveGrant(ctx, "ndg_unknown", "")
_, err := svc.Authenticate(ctx, "ndg_unknown", "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
@ -188,7 +163,7 @@ var _ = Describe("Service: grants and tokens", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
now = now.Add(IdleExpiry + time.Second)
_, err := svc.ResolveGrant(ctx, issued.Secret, "")
_, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).To(MatchError(model.ErrNotFound))
@ -204,7 +179,7 @@ var _ = Describe("Service: grants and tokens", func() {
now = now.Add(IdleExpiry + time.Second)
racing.SetClock(func() time.Time { return now })
_, err := racing.ResolveGrant(ctx, issued.Secret, "")
_, err := racing.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
g, err := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).ToNot(HaveOccurred())
@ -216,7 +191,7 @@ var _ = Describe("Service: grants and tokens", func() {
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
u.NewPassword = "changed-elsewhere"
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
_, err := svc.ResolveGrant(ctx, issued.Secret, "")
_, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).To(MatchError(model.ErrNotFound))

View file

@ -1,159 +0,0 @@
package apiauth
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
)
const Audience = "navidrome-api-v1"
// Tokens minted on one node are verified on others, whose clocks may differ slightly.
const clockSkew = 30 * time.Second
var ErrTokenExpired = errors.New("access token expired")
type claims struct {
UserID string
GrantID string
Scopes []string
IssuedAt time.Time
ExpiresAt time.Time
}
type signer struct {
auth *jwtauth.JWTAuth
}
func newJWTAuth(key []byte, now func() time.Time) *jwtauth.JWTAuth {
return jwtauth.New("HS256", key, nil,
jwt.WithAudience(Audience),
jwt.WithClock(jwt.ClockFunc(now)),
jwt.WithAcceptableSkew(clockSkew),
// jwx accepts a token with no exp at all unless the claim is required.
jwt.WithRequiredClaim(jwt.ExpirationKey),
jwt.WithRequiredClaim(jwt.SubjectKey),
)
}
// loadSigner reads the shared v1 key, creating it insert-if-absent so concurrent nodes agree on one key.
func loadSigner(ctx context.Context, ds model.DataStore, now func() time.Time) (*signer, error) {
key, err := loadKey(ctx, ds)
if err != nil {
return nil, err
}
return &signer{auth: newJWTAuth([]byte(key), now)}, nil
}
func loadKey(ctx context.Context, ds model.DataStore) (string, error) {
stored, err := ds.Property().Get(ctx, consts.JWTAPIv1SecretKey)
if errors.Is(err, model.ErrNotFound) {
stored, err = createKey(ctx, ds)
}
if err != nil {
return "", fmt.Errorf("reading API v1 key: %w", err)
}
if key, err := utils.Decrypt(ctx, auth.EncryptionKey(), stored); err == nil {
return key, nil
}
// A changed PasswordEncryptionKey makes the old key unreadable; replacing it only ends current access tokens.
// The lock and re-read make concurrent nodes agree on one replacement.
var key string
err = ds.WithTxImmediate(func(tx model.DataStore) error {
current, err := tx.Property().Get(ctx, consts.JWTAPIv1SecretKey)
if err != nil {
return err
}
if k, err := utils.Decrypt(ctx, auth.EncryptionKey(), current); err == nil {
key = k
return nil
}
log.Warn(ctx, "Could not decrypt API v1 key, replacing it")
k, enc, err := newEncryptedKey(ctx)
if err != nil {
return err
}
ctx = log.WithSecrets(ctx, enc)
if err := tx.Property().Put(ctx, consts.JWTAPIv1SecretKey, enc); err != nil {
return err
}
key = k
return nil
})
if err != nil {
return "", fmt.Errorf("replacing API v1 key: %w", err)
}
return key, nil
}
// createKey re-reads after the insert-if-absent, so nodes racing to create the key agree on the winner.
func createKey(ctx context.Context, ds model.DataStore) (string, error) {
_, enc, err := newEncryptedKey(ctx)
if err != nil {
return "", err
}
ctx = log.WithSecrets(ctx, enc)
if err := ds.Property().PutIfAbsent(ctx, consts.JWTAPIv1SecretKey, enc); err != nil {
return "", fmt.Errorf("storing API v1 key: %w", err)
}
return ds.Property().Get(ctx, consts.JWTAPIv1SecretKey)
}
func newEncryptedKey(ctx context.Context) (key, enc string, err error) {
key = newKey()
if enc, err = utils.Encrypt(ctx, auth.EncryptionKey(), key); err != nil {
return "", "", fmt.Errorf("encrypting API v1 key: %w", err)
}
return key, enc, nil
}
// newKey returns 256 random bits, the minimum RFC 7518 asks of an HS256 key.
func newKey() string {
b := make([]byte, 32)
_, _ = rand.Read(b) // never fails since Go 1.24
return hex.EncodeToString(b)
}
func (s *signer) sign(c claims) (string, error) {
_, tok, err := s.auth.Encode(map[string]any{
jwt.SubjectKey: c.UserID,
jwt.AudienceKey: []string{Audience},
jwt.IssuedAtKey: c.IssuedAt,
jwt.ExpirationKey: c.ExpiresAt,
"gid": c.GrantID,
"scope": strings.Join(c.Scopes, " "),
})
return tok, err
}
func (s *signer) parse(token string) (claims, error) {
tok, err := jwtauth.VerifyToken(s.auth, token)
if errors.Is(err, jwtauth.ErrExpired) {
return claims{}, ErrTokenExpired
}
if err != nil {
return claims{}, model.ErrInvalidAuth
}
var c claims
c.UserID, _ = tok.Subject()
c.IssuedAt, _ = tok.IssuedAt()
c.ExpiresAt, _ = tok.Expiration()
var scope string
if tok.Get("gid", &c.GrantID) != nil || tok.Get("scope", &scope) != nil || c.UserID == "" || c.GrantID == "" {
return claims{}, model.ErrInvalidAuth
}
c.Scopes = strings.Fields(scope)
return c, nil
}

View file

@ -1,153 +0,0 @@
package apiauth
import (
"context"
"encoding/hex"
"sync"
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("signer", func() {
var ctx context.Context
var now time.Time
clock := func() time.Time { return now }
BeforeEach(func() {
ctx = GinkgoT().Context()
now = time.Now().UTC().Truncate(time.Second)
Expect(realDS.Property().Delete(ctx, consts.JWTAPIv1SecretKey)).To(Or(Succeed(), MatchError(model.ErrNotFound)))
})
It("creates a 256-bit key", func() {
key, err := loadKey(ctx, realDS)
Expect(err).ToNot(HaveOccurred())
raw, err := hex.DecodeString(key)
Expect(err).ToNot(HaveOccurred())
Expect(raw).To(HaveLen(32))
})
It("keeps using a stored key created in the older format", func() {
enc, err := utils.Encrypt(ctx, auth.EncryptionKey(), "legacy22charskeyABCDEF")
Expect(err).ToNot(HaveOccurred())
Expect(realDS.Property().Put(ctx, consts.JWTAPIv1SecretKey, enc)).To(Succeed())
Expect(loadKey(ctx, realDS)).To(Equal("legacy22charskeyABCDEF"))
})
It("round-trips claims", func() {
s, err := loadSigner(ctx, realDS, clock)
Expect(err).ToNot(HaveOccurred())
tok, err := s.sign(claims{UserID: "u1", GrantID: "g1", Scopes: []string{"read", "password"},
IssuedAt: now, ExpiresAt: now.Add(time.Hour)})
Expect(err).ToNot(HaveOccurred())
c, err := s.parse(tok)
Expect(err).ToNot(HaveOccurred())
Expect(c.UserID).To(Equal("u1"))
Expect(c.GrantID).To(Equal("g1"))
Expect(c.Scopes).To(Equal([]string{"read", "password"}))
Expect(c.ExpiresAt).To(BeTemporally("==", now.Add(time.Hour)))
})
It("round-trips a token with no scopes", func() {
s, _ := loadSigner(ctx, realDS, clock)
tok, _ := s.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now, ExpiresAt: now.Add(time.Hour)})
c, err := s.parse(tok)
Expect(err).ToNot(HaveOccurred())
Expect(c.Scopes).To(BeEmpty())
})
It("reports expiry distinctly", func() {
s, _ := loadSigner(ctx, realDS, clock)
tok, _ := s.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now, ExpiresAt: now.Add(time.Hour)})
now = now.Add(time.Hour + clockSkew + time.Second)
_, err := s.parse(tok)
Expect(err).To(MatchError(ErrTokenExpired))
})
It("accepts a token issued slightly ahead of the verifier's clock", func() {
s, _ := loadSigner(ctx, realDS, clock)
tok, _ := s.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now.Add(5 * time.Second), ExpiresAt: now.Add(time.Hour)})
_, err := s.parse(tok)
Expect(err).ToNot(HaveOccurred())
})
It("rejects a token issued further ahead than the allowed skew", func() {
s, _ := loadSigner(ctx, realDS, clock)
tok, _ := s.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now.Add(clockSkew + time.Second), ExpiresAt: now.Add(time.Hour)})
_, err := s.parse(tok)
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("rejects garbage and tokens signed with another key", func() {
s, _ := loadSigner(ctx, realDS, clock)
_, err := s.parse("not-a-token")
Expect(err).To(MatchError(model.ErrInvalidAuth))
other := &signer{}
*other = *s
other.auth = newJWTAuth([]byte("another-key"), clock)
tok, _ := other.sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now, ExpiresAt: now.Add(time.Hour)})
_, err = s.parse(tok)
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("rejects a correctly signed token with no exp, or with another audience", func() {
s, _ := loadSigner(ctx, realDS, clock)
_, noExp, err := s.auth.Encode(map[string]any{
jwt.SubjectKey: "u1", jwt.AudienceKey: []string{Audience}, "gid": "g1", "scope": "read",
})
Expect(err).ToNot(HaveOccurred())
_, err = s.parse(noExp)
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, otherAud, _ := s.auth.Encode(map[string]any{
jwt.SubjectKey: "u1", jwt.AudienceKey: []string{"navidrome-other"}, jwt.ExpirationKey: now.Add(time.Hour),
"gid": "g1", "scope": "read",
})
_, err = s.parse(otherAud)
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
loadConcurrently := func() []*signer {
var wg sync.WaitGroup
signers := make([]*signer, 4)
for i := range signers {
wg.Add(1)
go func() {
defer GinkgoRecover()
defer wg.Done()
var err error
signers[i], err = loadSigner(ctx, realDS, clock)
Expect(err).ToNot(HaveOccurred())
}()
}
wg.Wait()
return signers
}
expectSameKey := func(signers []*signer) {
tok, _ := signers[0].sign(claims{UserID: "u1", GrantID: "g1", IssuedAt: now, ExpiresAt: now.Add(time.Hour)})
for _, s := range signers[1:] {
_, err := s.parse(tok)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
}
}
It("gives every concurrent loader the same key", func() {
expectSameKey(loadConcurrently())
})
It("gives every concurrent loader the same replacement for an unreadable key", func() {
Expect(realDS.Property().Put(ctx, consts.JWTAPIv1SecretKey, "not-decryptable")).To(Succeed())
expectSameKey(loadConcurrently())
})
})

View file

@ -4,7 +4,6 @@ import "context"
type PropertyRepository interface {
Put(ctx context.Context, id string, value string) error
PutIfAbsent(ctx context.Context, id string, value string) error
Get(ctx context.Context, id string) (string, error)
Delete(ctx context.Context, id string) error
DefaultGet(ctx context.Context, id string, defaultValue string) (string, error)

View file

@ -34,12 +34,6 @@ func (r propertyRepository) Put(ctx context.Context, id string, value string) er
return err
}
func (r propertyRepository) PutIfAbsent(ctx context.Context, id string, value string) error {
insert := Insert(r.tableName).Columns("id", "value").Values(id, value).Options("OR IGNORE")
_, err := r.executeSQL(ctx, insert)
return err
}
func (r propertyRepository) Get(ctx context.Context, id string) (string, error) {
sel := Select("value").From(r.tableName).Where(Eq{"id": id})
resp := struct {

View file

@ -34,27 +34,18 @@ var _ = Describe("Property Repository", func() {
Expect(pr.DefaultGet(ctx, "2", "default")).To(Equal("default"))
})
It("PutIfAbsent inserts once and never overwrites", func() {
Expect(pr.PutIfAbsent(ctx, "pia", "first")).To(Succeed())
Expect(pr.PutIfAbsent(ctx, "pia", "second")).To(Succeed())
Expect(pr.Get(ctx, "pia")).To(Equal("first"))
})
It("hides values marked as secrets from the SQL log, but still logs the property id", func() {
logs := captureTraceLogs()
insertCtx := log.WithSecrets(ctx, "inserted-secret")
Expect(pr.Put(insertCtx, "secret-prop", "inserted-secret")).To(Succeed())
updateCtx := log.WithSecrets(ctx, "updated-secret")
Expect(pr.Put(updateCtx, "secret-prop", "updated-secret")).To(Succeed())
absentCtx := log.WithSecrets(ctx, "absent-secret")
Expect(pr.PutIfAbsent(absentCtx, "secret-prop-2", "absent-secret")).To(Succeed())
Expect(logs.String()).To(ContainSubstring("INSERT INTO property"))
Expect(logs.String()).To(ContainSubstring("UPDATE property"))
Expect(logs.String()).To(ContainSubstring("secret-prop-2"))
Expect(logs.String()).To(ContainSubstring("secret-prop"))
Expect(logs.String()).ToNot(ContainSubstring("inserted-secret"))
Expect(logs.String()).ToNot(ContainSubstring("updated-secret"))
Expect(logs.String()).ToNot(ContainSubstring("absent-secret"))
})
It("logs the values of unmarked property writes", func() {

View file

@ -32,10 +32,9 @@ func New(ds model.DataStore) *Router {
}
var gateRulesV1 = gateRules{
limited: map[string]bool{"login": true, "setupFirstAdmin": true, "changePassword": true},
noScope: map[string]bool{"getCapabilities": true},
grantOps: map[string]bool{"createAccessToken": true},
noStore: map[string]bool{"login": true, "setupFirstAdmin": true, "createAccessToken": true},
limited: map[string]bool{"login": true, "setupFirstAdmin": true, "changePassword": true},
noScope: map[string]bool{"getCapabilities": true},
noStore: map[string]bool{"login": true, "setupFirstAdmin": true},
}
func (rt *Router) routes() http.Handler {

View file

@ -9,7 +9,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
@ -17,21 +16,6 @@ import (
"github.com/oapi-codegen/runtime"
)
// Defines values for AccessTokenTokenType.
const (
AccessTokenTokenTypeBearer AccessTokenTokenType = "Bearer"
)
// Valid indicates whether the value is a known member of the AccessTokenTokenType enum.
func (e AccessTokenTokenType) Valid() bool {
switch e {
case AccessTokenTokenTypeBearer:
return true
default:
return false
}
}
// Defines values for ProblemCode.
const (
ProblemCodeForbidden ProblemCode = "forbidden"
@ -43,7 +27,6 @@ const (
ProblemCodePayloadTooLarge ProblemCode = "payload_too_large"
ProblemCodeRateLimited ProblemCode = "rate_limited"
ProblemCodeSetupComplete ProblemCode = "setup_complete"
ProblemCodeTokenExpired ProblemCode = "token_expired"
ProblemCodeUnauthorized ProblemCode = "unauthorized"
ProblemCodeUnavailable ProblemCode = "unavailable"
ProblemCodeValidation ProblemCode = "validation"
@ -70,8 +53,6 @@ func (e ProblemCode) Valid() bool {
return true
case ProblemCodeSetupComplete:
return true
case ProblemCodeTokenExpired:
return true
case ProblemCodeUnauthorized:
return true
case ProblemCodeUnavailable:
@ -104,24 +85,6 @@ func (e Scope) Valid() bool {
}
}
// AccessToken A short-lived access token. Opaque; clients must not decode it.
type AccessToken struct {
// AccessToken The token. Send it as `Authorization: Bearer <token>`.
AccessToken string `json:"accessToken"`
// ExpiresIn Seconds until the token expires.
ExpiresIn int `json:"expiresIn"`
// Scopes Scopes the token actually carries, which may be fewer than requested.
Scopes []Scope `json:"scopes"`
// TokenType Always `Bearer`.
TokenType AccessTokenTokenType `json:"tokenType"`
}
// AccessTokenTokenType Always `Bearer`.
type AccessTokenTokenType string
// AuthUser The user a grant belongs to.
type AuthUser struct {
// Id User id.
@ -142,7 +105,7 @@ type AuthUser struct {
// Capabilities Capability modules this server implements, keyed by module. Keys are optional; a missing key means the
// module is not implemented. New modules are added as new optional keys. These are server facts, not what
// the calling token may use.
// the calling grant may use.
type Capabilities struct {
// Core The mandatory core module.
Core *CoreCapability `json:"core,omitempty"`
@ -207,7 +170,7 @@ type Grant struct {
// Provider How the grant was created, for example `password` or `setup`. Free-form; new values may appear.
Provider string `json:"provider"`
// Scopes Scopes this grant may mint tokens for.
// Scopes Scopes this grant carries.
Scopes []Scope `json:"scopes"`
}
@ -216,7 +179,7 @@ type GrantCreated struct {
// Grant The new grant.
Grant Grant `json:"grant"`
// Secret Opaque grant secret. Send it as a Bearer credential to `POST /auth/token`.
// Secret Opaque grant secret. Send it as `Authorization: Bearer <secret>`.
Secret string `json:"secret"`
// User The user the grant belongs to.
@ -329,12 +292,6 @@ type ServerInfo struct {
SpecVersion string `json:"specVersion"`
}
// TokenRequest Optional narrowing of a new access token.
type TokenRequest struct {
// Scopes Subset of the grant's scopes. Omit for all of them; an empty list asks for none.
Scopes *[]ScopeRequest `json:"scopes,omitempty"`
}
// ValidationError One field-level validation failure.
type ValidationError struct {
// Field Name of the offending query parameter, path parameter, or body field (dotted for nested).
@ -392,9 +349,6 @@ type ChangePasswordJSONRequestBody = PasswordChangeRequest
// SetupFirstAdminJSONRequestBody defines body for SetupFirstAdmin for application/json ContentType.
type SetupFirstAdminJSONRequestBody = CredentialsRequest
// CreateAccessTokenJSONRequestBody defines body for CreateAccessToken for application/json ContentType.
type CreateAccessTokenJSONRequestBody = TokenRequest
// ServerInterface represents all server handlers.
type ServerInterface interface {
// ListGrants List my grants
@ -415,9 +369,6 @@ type ServerInterface interface {
// SetupFirstAdmin Create the first admin
// (POST /auth/setup)
SetupFirstAdmin(w http.ResponseWriter, r *http.Request)
// CreateAccessToken Mint an access token
// (POST /auth/token)
CreateAccessToken(w http.ResponseWriter, r *http.Request)
// GetCapabilities List implemented capability modules
// (GET /capabilities)
GetCapabilities(w http.ResponseWriter, r *http.Request)
@ -466,12 +417,6 @@ func (_ Unimplemented) SetupFirstAdmin(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// CreateAccessToken Mint an access token
// (POST /auth/token)
func (_ Unimplemented) CreateAccessToken(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// GetCapabilities List implemented capability modules
// (GET /capabilities)
func (_ Unimplemented) GetCapabilities(w http.ResponseWriter, r *http.Request) {
@ -621,20 +566,6 @@ func (siw *ServerInterfaceWrapper) SetupFirstAdmin(w http.ResponseWriter, r *htt
handler.ServeHTTP(w, r)
}
// CreateAccessToken operation middleware
func (siw *ServerInterfaceWrapper) CreateAccessToken(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.CreateAccessToken(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetCapabilities operation middleware
func (siw *ServerInterfaceWrapper) GetCapabilities(w http.ResponseWriter, r *http.Request) {
@ -794,9 +725,6 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/auth/setup", wrapper.SetupFirstAdmin)
})
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/auth/token", wrapper.CreateAccessToken)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/capabilities", wrapper.GetCapabilities)
})
@ -1483,105 +1411,6 @@ func (response SetupFirstAdmin500ApplicationProblemPlusJSONResponse) VisitSetupF
return err
}
type CreateAccessTokenRequestObject struct {
Body *CreateAccessTokenJSONRequestBody
}
type CreateAccessTokenResponseObject interface {
VisitCreateAccessTokenResponse(w http.ResponseWriter) error
}
type CreateAccessToken200ResponseHeaders struct {
CacheControl *string
}
type CreateAccessToken200JSONResponse struct {
Body AccessToken
Headers CreateAccessToken200ResponseHeaders
}
func (response CreateAccessToken200JSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(response.Body); err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
if response.Headers.CacheControl != nil {
w.Header().Set("Cache-Control", fmt.Sprint(*response.Headers.CacheControl))
}
w.WriteHeader(200)
_, err := buf.WriteTo(w)
return err
}
type CreateAccessToken400ApplicationProblemPlusJSONResponse struct {
BadRequestApplicationProblemPlusJSONResponse
}
func (response CreateAccessToken400ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(response); err != nil {
return err
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(400)
_, err := buf.WriteTo(w)
return err
}
type CreateAccessToken401ApplicationProblemPlusJSONResponse struct {
UnauthorizedApplicationProblemPlusJSONResponse
}
func (response CreateAccessToken401ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(response.Body); err != nil {
return err
}
w.Header().Set("Content-Type", "application/problem+json")
if response.Headers.WWWAuthenticate != nil {
w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate))
}
w.WriteHeader(401)
_, err := buf.WriteTo(w)
return err
}
type CreateAccessToken413ApplicationProblemPlusJSONResponse struct {
PayloadTooLargeApplicationProblemPlusJSONResponse
}
func (response CreateAccessToken413ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(response); err != nil {
return err
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(413)
_, err := buf.WriteTo(w)
return err
}
type CreateAccessToken500ApplicationProblemPlusJSONResponse struct {
InternalErrorApplicationProblemPlusJSONResponse
}
func (response CreateAccessToken500ApplicationProblemPlusJSONResponse) VisitCreateAccessTokenResponse(w http.ResponseWriter) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(response); err != nil {
return err
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(500)
_, err := buf.WriteTo(w)
return err
}
type GetCapabilitiesRequestObject struct {
}
@ -1695,9 +1524,6 @@ type StrictServerInterface interface {
// SetupFirstAdmin Create the first admin
// (POST /auth/setup)
SetupFirstAdmin(ctx context.Context, request SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error)
// CreateAccessToken Mint an access token
// (POST /auth/token)
CreateAccessToken(ctx context.Context, request CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error)
// GetCapabilities List implemented capability modules
// (GET /capabilities)
GetCapabilities(ctx context.Context, request GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error)
@ -1914,40 +1740,6 @@ func (sh *strictHandler) SetupFirstAdmin(w http.ResponseWriter, r *http.Request)
}
}
// CreateAccessToken operation middleware
func (sh *strictHandler) CreateAccessToken(w http.ResponseWriter, r *http.Request) {
var request CreateAccessTokenRequestObject
var body CreateAccessTokenJSONRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if !errors.Is(err, io.EOF) {
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
return
}
} else {
request.Body = &body
}
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.CreateAccessToken(ctx, request.(CreateAccessTokenRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "CreateAccessToken")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(CreateAccessTokenResponseObject); ok {
if err := validResponse.VisitCreateAccessTokenResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// GetCapabilities operation middleware
func (sh *strictHandler) GetCapabilities(w http.ResponseWriter, r *http.Request) {
var request GetCapabilitiesRequestObject

View file

@ -97,12 +97,17 @@ func (c testClient) setup() GrantCreated {
return gc
}
func (c testClient) mint(secret string, body any) AccessToken {
w := c.call(http.MethodPost, "/api/v1/auth/token", secret, body)
// login signs in as the admin created by setup; nil scopes asks for all of them.
func (c testClient) login(scopes []string) GrantCreated {
body := creds("admin", "pw")
if scopes != nil {
body["scopes"] = scopes
}
w := c.call(http.MethodPost, "/api/v1/auth/login", "", body)
ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK), w.Body.String())
var at AccessToken
decodeJSON(w, &at)
return at
var gc GrantCreated
decodeJSON(w, &gc)
return gc
}
func creds(user, pw string) map[string]any {

View file

@ -9,27 +9,6 @@ import (
const defaultPageSize = 100
func (rt *Router) CreateAccessToken(ctx context.Context, req CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error) {
p, err := 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{Body: AccessToken{
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 := principalFrom(ctx)
if err != nil {

View file

@ -60,7 +60,7 @@ var _ = Describe("auth endpoints", func() {
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeSetupComplete))
})
It("logs in, mints a token, and uses it on a scoped endpoint", func() {
It("logs in and uses the grant secret on a scoped endpoint", func() {
api.setup()
w := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("ADMIN", "pw"))
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
@ -68,11 +68,7 @@ var _ = Describe("auth endpoints", func() {
decodeJSON(w, &gc)
Expect(gc.User.PasswordChangeable).To(BeTrue())
at := api.mint(gc.Secret, nil)
Expect(at.TokenType).To(Equal(AccessTokenTokenTypeBearer))
Expect(at.ExpiresIn).To(Equal(3600))
w = api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil)
w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
var list GrantList
decodeJSON(w, &list)
@ -89,69 +85,64 @@ var _ = Describe("auth endpoints", func() {
Expect(a.Body.String()).To(Equal(b.Body.String()))
})
It("treats no body and {} as all scopes, and [] as no scopes", func() {
gc := api.setup()
all := api.mint(gc.Secret, nil)
Expect(all.Scopes).To(ConsistOf(ScopeRead, ScopePassword))
Expect(api.mint(gc.Secret, map[string]any{}).Scopes).To(ConsistOf(ScopeRead, ScopePassword))
It("treats missing scopes as all scopes, and [] as no scopes", func() {
api.setup()
all := api.login(nil)
Expect(all.Grant.Scopes).To(ConsistOf(ScopeAll))
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", all.Secret, nil).Code).To(Equal(http.StatusOK))
none := api.mint(gc.Secret, map[string]any{"scopes": []string{}})
Expect(none.Scopes).To(BeEmpty())
w := api.call(http.MethodGet, "/api/v1/auth/grants", none.AccessToken, nil)
none := api.login([]string{})
Expect(none.Grant.Scopes).To(BeEmpty())
w := api.call(http.MethodGet, "/api/v1/auth/grants", none.Secret, nil)
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="read"`))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope))
})
It("drops unknown requested scopes instead of rejecting them", func() {
gc := api.setup()
at := api.mint(gc.Secret, map[string]any{"scopes": []string{"read", "playlists:write"}})
Expect(at.Scopes).To(ConsistOf(ScopeRead))
api.setup()
gc := api.login([]string{"read", "playlists:write"})
Expect(gc.Grant.Scopes).To(ConsistOf(ScopeRead))
})
It("does not let a token without read log out or revoke grants", func() {
It("does not let a grant without read log out or revoke grants", func() {
gc := api.setup()
narrow := api.mint(gc.Secret, map[string]any{"scopes": []string{"password"}})
Expect(api.call(http.MethodPost, "/api/v1/auth/logout", narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden))
Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden))
narrow := api.login([]string{"password"})
Expect(api.call(http.MethodPost, "/api/v1/auth/logout", narrow.Secret, nil).Code).To(Equal(http.StatusForbidden))
Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.Secret, nil).Code).To(Equal(http.StatusForbidden))
})
It("logs out: the token stops at once and logoutUrl is null", func() {
It("logs out: the secret stops at once and logoutUrl is null", func() {
gc := api.setup()
at := api.mint(gc.Secret, nil)
w := api.call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil)
w := api.call(http.MethodPost, "/api/v1/auth/logout", gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`))
w = api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil)
w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(api.call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil).Code).To(Equal(http.StatusUnauthorized))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
})
It("logs out with 200 when another node already revoked the grant", func() {
It("revokes another grant of the caller, whose secret then stops at once", func() {
gc := api.setup()
at := api.mint(gc.Secret, nil)
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant
Expect(realDS.Grant().DeleteForUser(ctx, gc.User.Id, gc.Grant.Id)).To(Succeed())
w := api.call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil)
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`))
other := api.login(nil)
Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+other.Grant.Id, gc.Secret, nil).Code).To(Equal(http.StatusNoContent))
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized))
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK))
})
It("challenges with invalid_token when the grant is revoked while a password change runs", func() {
gc := api.setup()
at := api.mint(gc.Secret, nil)
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant
Expect(realDS.Grant().DeleteForUser(ctx, gc.User.Id, gc.Grant.Id)).To(Succeed())
w := api.call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"})
w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "pw", "newPassword": "pw2"})
Expect(w.Code).To(Equal(http.StatusUnauthorized), w.Body.String())
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
})
It("rejects a case-variant scopes key that would widen an explicit empty subset", func() {
gc := api.setup()
w := api.callRaw(http.MethodPost, "/api/v1/auth/token", gc.Secret, `{"scopes":[],"Scopes":null}`)
api.setup()
w := api.callRaw(http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"pw","client":"c","scopes":[],"Scopes":null}`)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
@ -167,33 +158,30 @@ var _ = Describe("auth endpoints", func() {
})
DescribeTable("rejects a body with data after its JSON value, without echoing it",
func(path string, needsSecret bool, body string) {
secret := ""
if gc := api.setup(); needsSecret {
secret = gc.Secret
}
w := api.callRaw(http.MethodPost, path, secret, body)
func(body string) {
api.setup()
w := api.callRaw(http.MethodPost, "/api/v1/auth/login", "", body)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "", Message: "must be a single JSON value"}))
Expect(w.Body.String()).ToNot(ContainSubstring("hunter2"))
},
Entry("token with a trailing byte", "/api/v1/auth/token", true, `{"scopes":[],"Scopes":null}x`),
Entry("login with a second value", "/api/v1/auth/login", false, `{"username":"a","password":"hunter2","client":"c"} {}`),
Entry("a trailing byte", `{"username":"a","password":"hunter2","client":"c"}x`),
Entry("a second value", `{"username":"a","password":"hunter2","client":"c"} {}`),
)
It("checks the token body even when Content-Type has a repeated parameter", func() {
gc := api.setup()
req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/token", strings.NewReader(`{"scopes":[],"Scopes":null}`))
It("checks the login body even when Content-Type has a repeated parameter", func() {
api.setup()
body := `{"username":"admin","password":"pw","client":"c","scopes":[],"Scopes":null}`
req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json; a=1; a=2")
req.Header.Set("Authorization", "Bearer "+gc.Secret)
w := serve(api.router, req)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "Scopes", Message: "must match the field name exactly"}))
})
It("marks grant and token responses no-store", func() {
It("marks responses carrying a grant secret no-store", func() {
w := api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw"))
Expect(w.Code).To(Equal(http.StatusCreated))
Expect(w.Header().Get("Cache-Control")).To(Equal("no-store"))
@ -204,38 +192,45 @@ var _ = Describe("auth endpoints", func() {
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Cache-Control")).To(Equal("no-store"))
w = api.call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil)
w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Cache-Control")).To(Equal("no-store"))
Expect(w.Header().Get("Cache-Control")).To(BeEmpty())
})
It("answers 404 for a grant id the caller does not own, and 400 for an over-long id", func() {
gc := api.setup()
tok := api.mint(gc.Secret, nil).AccessToken
Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/does-not-exist", tok, nil).Code).To(Equal(http.StatusNotFound))
w := api.call(http.MethodDelete, "/api/v1/auth/grants/"+strings.Repeat("x", 65), tok, nil)
Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/does-not-exist", gc.Secret, nil).Code).To(Equal(http.StatusNotFound))
w := api.call(http.MethodDelete, "/api/v1/auth/grants/"+strings.Repeat("x", 65), gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"}))
})
It("changes the password, keeping the caller and revoking the rest", func() {
gc := api.setup()
otherLogin := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw"))
var other GrantCreated
decodeJSON(otherLogin, &other)
at := api.mint(gc.Secret, nil)
other := api.login(nil)
w := api.call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"})
w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "pw", "newPassword": "pw2"})
Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String())
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK))
Expect(api.call(http.MethodPost, "/api/v1/auth/token", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized))
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK))
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized))
})
It("keeps every grant when revokeOtherGrants is false", func() {
gc := api.setup()
other := api.login(nil)
body := map[string]any{"currentPassword": "pw", "newPassword": "pw2", "revokeOtherGrants": false}
w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, body)
Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String())
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK))
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusOK))
})
It("reports a wrong current password as a field error", func() {
gc := api.setup()
at := api.mint(gc.Secret, nil)
w := api.call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "nope", "newPassword": "pw2"})
w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "nope", "newPassword": "pw2"})
Expect(w.Code).To(Equal(http.StatusBadRequest))
p := decodeProblem(w)
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"}))

View file

@ -20,16 +20,17 @@ var _ = Describe("GET /capabilities", func() {
api = testClient{ctx: ctx, router: New(realDS)}
})
It("needs a token", func() {
It("needs a grant", func() {
w := api.call(http.MethodGet, "/api/v1/capabilities", "", nil)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("lists core and password for any valid token, even one with no scopes", func() {
gc := api.setup()
at := api.mint(gc.Secret, map[string]any{"scopes": []string{}})
It("lists core and password for any valid grant, even one with no scopes", func() {
api.setup()
gc := api.login([]string{})
Expect(gc.Grant.Scopes).To(BeEmpty())
w := api.call(http.MethodGet, "/api/v1/capabilities", at.AccessToken, nil)
w := api.call(http.MethodGet, "/api/v1/capabilities", gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusOK))
var caps Capabilities
decodeJSON(w, &caps)

View file

@ -28,16 +28,14 @@ import (
)
type authenticator interface {
Authenticate(ctx context.Context, token, ip string) (*apiauth.Principal, error)
ResolveGrant(ctx context.Context, secret, ip string) (*apiauth.Principal, error)
Authenticate(ctx context.Context, secret, ip string) (*apiauth.Principal, error)
}
type authKind int
const (
authPublic authKind = iota
authToken
authGrant
authBearer
)
type gateOp struct {
@ -69,10 +67,9 @@ var moduleScope = map[string]string{
}
type gateRules struct {
limited map[string]bool // login-type operations, throttled per client IP
noScope map[string]bool // the only token operations allowed without x-scope
grantOps map[string]bool // the only operations allowed to use grantAuth
noStore map[string]bool // operations whose responses carry a secret or token
limited map[string]bool // login-type operations, throttled per client IP
noScope map[string]bool // the only bearerAuth operations allowed without x-scope
noStore map[string]bool // operations whose responses carry a secret
}
func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRules) (*gate, error) {
@ -104,7 +101,7 @@ func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRule
// check fails on a rule naming an operation the spec lacks, so a typo cannot silently disable the rule.
func (rules gateRules) check(ids map[string]bool) error {
sets := map[string]map[string]bool{"limited": rules.limited, "noScope": rules.noScope, "grantOps": rules.grantOps, "noStore": rules.noStore}
sets := map[string]map[string]bool{"limited": rules.limited, "noScope": rules.noScope, "noStore": rules.noStore}
for name, set := range sets {
for id := range set {
if !ids[id] {
@ -136,17 +133,15 @@ func buildGateOp(doc *openapi3.T, path string, item *openapi3.PathItem, method s
case len(reqs) == 0:
gop.kind = authPublic
case len(reqs) == 1 && isScheme(reqs[0], "bearerAuth"):
gop.kind = authToken
case len(reqs) == 1 && isScheme(reqs[0], "grantAuth") && rules.grantOps[id]:
gop.kind = authGrant
gop.kind = authBearer
default:
return nil, fmt.Errorf("operation %s has a security requirement outside the allowed forms", id)
}
if gop.kind == authToken && scope == "" && !rules.noScope[id] {
if gop.kind == authBearer && scope == "" && !rules.noScope[id] {
return nil, fmt.Errorf("operation %s: bearerAuth needs x-scope", id)
}
if scope != "" {
if gop.kind != authToken {
if gop.kind != authBearer {
return nil, fmt.Errorf("operation %s: x-scope needs bearerAuth", id)
}
base := cmp.Or(moduleScope[module], module)
@ -226,23 +221,13 @@ func (g *gate) authorize(w http.ResponseWriter, r *http.Request, op *gateOp) (*h
if op.kind == authPublic {
return r, true
}
token, ok := bearerToken(r)
secret, ok := bearerToken(r)
if !ok {
writeProblemStatus(w, r, http.StatusUnauthorized, ProblemCodeUnauthorized, "")
return r, false
}
ip := server.ClientAddr(r)
var p *apiauth.Principal
var err error
if op.kind == authGrant {
p, err = g.auth.ResolveGrant(r.Context(), token, ip)
} else {
p, err = g.auth.Authenticate(r.Context(), token, ip)
}
p, err := g.auth.Authenticate(r.Context(), secret, server.ClientAddr(r))
if err == nil && op.scope != "" && !apiauth.Satisfies(p.Scopes, op.scope) {
err = apiauth.ErrInsufficientScope
}
if errors.Is(err, apiauth.ErrInsufficientScope) {
err = &scopeError{scope: op.scope}
}
if err != nil {

View file

@ -53,39 +53,29 @@ paths:
responses: {'200': {description: ok}}
/caps:
get: {operationId: caps, x-module: core, security: [{bearerAuth: []}], responses: {'200': {description: ok}}}
/mint:
post: {operationId: mint, x-module: core, security: [{grantAuth: []}], responses: {'200': {description: ok}}}
/limited:
post: {operationId: limited, x-module: core, security: [], responses: {'200': {description: ok}}}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer}
grantAuth: {type: http, scheme: bearer}
`
type fakeAuth struct {
principal *apiauth.Principal
err error
gotToken string
gotSecret string
gotIP string
}
func (f *fakeAuth) Authenticate(_ context.Context, token, ip string) (*apiauth.Principal, error) {
f.gotToken, f.gotIP = token, ip
return f.principal, f.err
}
func (f *fakeAuth) ResolveGrant(_ context.Context, secret, ip string) (*apiauth.Principal, error) {
func (f *fakeAuth) Authenticate(_ context.Context, secret, ip string) (*apiauth.Principal, error) {
f.gotSecret, f.gotIP = secret, ip
return f.principal, f.err
}
var testGateRules = gateRules{
limited: map[string]bool{"limited": true},
noScope: map[string]bool{"caps": true},
grantOps: map[string]bool{"mint": true},
noStore: map[string]bool{"mint": true},
limited: map[string]bool{"limited": true},
noScope: map[string]bool{"caps": true},
noStore: map[string]bool{"caps": true},
}
var _ = Describe("spec gate", func() {
@ -117,7 +107,6 @@ var _ = Describe("spec gate", func() {
m.Get("/things/{id}", ok("getThing"))
m.Post("/things", ok("createThing"))
m.Get("/caps", ok("caps"))
m.Post("/mint", ok("mint"))
m.Post("/limited", ok("limited"))
return m, nil
}
@ -147,12 +136,12 @@ var _ = Describe("spec gate", func() {
Expect(err).ToNot(HaveOccurred())
})
It("lets public operations through without a token", func() {
It("lets public operations through without a credential", func() {
Expect(do(http.MethodGet, "/open", "", "").Code).To(Equal(http.StatusOK))
Expect(reached).To(Equal("open"))
})
It("requires a token, with a Bearer challenge", func() {
It("requires a grant secret, with a Bearer challenge", func() {
w := do(http.MethodGet, "/things/1", "", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
@ -161,21 +150,13 @@ var _ = Describe("spec gate", func() {
})
It("accepts the Bearer scheme in any case and trims spaces", func() {
w := do(http.MethodGet, "/things/1", "bearer tok-1 ", "")
w := do(http.MethodGet, "/things/1", "bearer ndg_secret ", "")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.gotToken).To(Equal("tok-1"))
Expect(fa.gotSecret).To(Equal("ndg_secret"))
Expect(w.Header().Get("X-User")).To(Equal("u1"))
})
It("maps an expired token to token_expired", func() {
fa.err = apiauth.ErrTokenExpired
w := do(http.MethodGet, "/things/1", "Bearer x", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeTokenExpired))
})
It("maps other auth failures to unauthorized with invalid_token", func() {
It("maps auth failures to unauthorized with invalid_token", func() {
fa.err = model.ErrInvalidAuth
w := do(http.MethodGet, "/things/1", "Bearer x", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
@ -183,44 +164,34 @@ var _ = Describe("spec gate", func() {
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized))
})
It("rejects a token without the operation's scope", func() {
It("rejects a grant without the operation's scope", func() {
w := do(http.MethodPost, "/things", "Bearer x", `{"name":"a"}`)
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="password"`))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope))
})
It("lets any valid token through an operation with no x-scope", func() {
It("lets any valid grant through an operation with no x-scope", func() {
fa.principal.Scopes = nil
Expect(do(http.MethodGet, "/caps", "Bearer x", "").Code).To(Equal(http.StatusOK))
})
DescribeTable("passes the full client address to the authenticator, not the rate-limit /64",
func(method, path string) {
req := httptest.NewRequestWithContext(ctx, method, path, nil)
req.RemoteAddr = "[2001:db8:1:2:3:4:5:6]:4321"
req.Header.Set("Authorization", "Bearer x")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.gotIP).To(Equal("2001:db8:1:2:3:4:5:6"))
},
Entry("access token", http.MethodGet, "/things/1"),
Entry("grant", http.MethodPost, "/mint"),
)
It("marks only the listed operations' responses no-store, errors included", func() {
Expect(do(http.MethodPost, "/mint", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store"))
fa.err = model.ErrInvalidAuth
Expect(do(http.MethodPost, "/mint", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store"))
fa.err = nil
Expect(do(http.MethodGet, "/things/1", "Bearer x", "").Header().Get("Cache-Control")).To(BeEmpty())
It("passes the full client address to the authenticator, not the rate-limit /64", func() {
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/things/1", nil)
req.RemoteAddr = "[2001:db8:1:2:3:4:5:6]:4321"
req.Header.Set("Authorization", "Bearer x")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.gotIP).To(Equal("2001:db8:1:2:3:4:5:6"))
})
It("uses ResolveGrant for grantAuth operations", func() {
Expect(do(http.MethodPost, "/mint", "Bearer ndg_secret", "").Code).To(Equal(http.StatusOK))
Expect(fa.gotSecret).To(Equal("ndg_secret"))
Expect(fa.gotToken).To(BeEmpty())
It("marks only the listed operations' responses no-store, errors included", func() {
Expect(do(http.MethodGet, "/caps", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store"))
fa.err = model.ErrInvalidAuth
Expect(do(http.MethodGet, "/caps", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store"))
fa.err = nil
Expect(do(http.MethodGet, "/things/1", "Bearer x", "").Header().Get("Cache-Control")).To(BeEmpty())
})
It("checks HEAD on a protected GET", func() {
@ -228,21 +199,6 @@ var _ = Describe("spec gate", func() {
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("turns an insufficient-scope error from Authenticate into a 403 challenge", func() {
fa.err = apiauth.ErrInsufficientScope // e.g. a token carrying admin after demotion
w := do(http.MethodGet, "/caps", "Bearer x", "")
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope"`))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope))
})
It("names the operation's scope when Authenticate reports an insufficient scope", func() {
fa.err = apiauth.ErrInsufficientScope
w := do(http.MethodGet, "/things/1", "Bearer x", "")
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="read"`))
})
It("looks routes up on the raw path, as chi dispatches them", func() {
w := do(http.MethodGet, "/things/a%2Fb", "", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
@ -389,7 +345,10 @@ var _ = Describe("spec gate", func() {
Entry("scope not matching module", strings.Replace(gateSpec, "x-scope: read", "x-scope: password", 1)),
Entry("unknown scope", strings.Replace(gateSpec, "x-scope: read", "x-scope: bogus", 1)),
Entry("bearer without x-scope outside the allowlist", strings.Replace(gateSpec, " x-scope: read\n", "", 1)),
Entry("grantAuth outside the allowlist", strings.Replace(gateSpec, "operationId: limited, x-module: core, security: []", "operationId: limited, x-module: core, security: [{grantAuth: []}]", 1)),
Entry("a scheme other than bearerAuth", strings.Replace(
strings.Replace(gateSpec, " bearerAuth: {type: http, scheme: bearer}\n", " bearerAuth: {type: http, scheme: bearer}\n grantAuth: {type: http, scheme: bearer}\n", 1),
"operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{grantAuth: []}]", 1)),
Entry("an undeclared scheme", strings.Replace(gateSpec, "operationId: limited, x-module: core, security: []", "operationId: limited, x-module: core, security: [{grantAuth: []}]", 1)),
Entry("non-empty scope list on a bearer scheme", strings.Replace(gateSpec, "operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{bearerAuth: [read]}]", 1)),
Entry("x-scope on a public operation", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core, x-scope: read, security: [],", 1)),
Entry("x-scope that is not a string", strings.Replace(gateSpec, "x-scope: read", "x-scope: [read]", 1)),
@ -409,7 +368,6 @@ var _ = Describe("spec gate", func() {
},
Entry("limited", func(r *gateRules) *map[string]bool { return &r.limited }),
Entry("noScope", func(r *gateRules) *map[string]bool { return &r.noScope }),
Entry("grantOps", func(r *gateRules) *map[string]bool { return &r.grantOps }),
Entry("noStore", func(r *gateRules) *map[string]bool { return &r.noStore }),
)

View file

@ -89,8 +89,6 @@ func classifyError(err error) (int, ProblemCode) {
switch {
case tooLarge(err):
return http.StatusRequestEntityTooLarge, ProblemCodePayloadTooLarge
case errors.Is(err, apiauth.ErrTokenExpired):
return http.StatusUnauthorized, ProblemCodeTokenExpired
case errors.Is(err, apiauth.ErrInsufficientScope):
return http.StatusForbidden, ProblemCodeInsufficientScope
case errors.Is(err, auth.ErrSetupComplete):

View file

@ -51,7 +51,6 @@ var _ = Describe("problem", func() {
Entry("expired", model.ErrExpired, http.StatusUnauthorized, ProblemCodeUnauthorized),
Entry("validation", model.ErrValidation, http.StatusBadRequest, ProblemCodeValidation),
Entry("not available", model.ErrNotAvailable, http.StatusServiceUnavailable, ProblemCodeUnavailable),
Entry("token expired", apiauth.ErrTokenExpired, http.StatusUnauthorized, ProblemCodeTokenExpired),
Entry("insufficient scope", apiauth.ErrInsufficientScope, http.StatusForbidden, ProblemCodeInsufficientScope),
Entry("setup complete", auth.ErrSetupComplete, http.StatusConflict, ProblemCodeSetupComplete),
Entry("password managed externally", apiauth.ErrPasswordManagedExternally, http.StatusConflict, ProblemCodePasswordManagedExternally),

View file

@ -27,17 +27,6 @@ func (p *MockedPropertyRepo) Put(_ context.Context, id string, value string) err
return nil
}
func (p *MockedPropertyRepo) PutIfAbsent(_ context.Context, id string, value string) error {
if p.Error != nil {
return p.Error
}
p.init()
if _, ok := p.Data[id]; !ok {
p.Data[id] = value
}
return nil
}
func (p *MockedPropertyRepo) Get(_ context.Context, id string) (string, error) {
if p.Error != nil {
return "", p.Error