mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-14 22:28:41 +01:00
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:
parent
052be800a7
commit
d1b876097f
37 changed files with 371 additions and 1656 deletions
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ properties:
|
|||
enum:
|
||||
- validation
|
||||
- unauthorized
|
||||
- token_expired
|
||||
- forbidden
|
||||
- insufficient_scope
|
||||
- not_found
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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`."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"}))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue