Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 149 additions & 1 deletion documentation/api/introduction.rst
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,155 @@ which gives a response like this if the credentials are correct:
"user_id": "<ID of the user>"
}

.. note:: Each access token has a limited lifetime, see :ref:`api_auth`.
Access tokens expire after six hours by default; the host can configure a different lifetime.
The existing request and response above remain unchanged. Authenticate again with your
email and password when the token expires, or opt in to refresh tokens as described below.
Use the token directly in the ``Authorization`` header, without a ``Bearer`` prefix.

.. _api_refresh_tokens:

Refresh tokens
^^^^^^^^^^^^^^

A refresh token lets an integration obtain a new access token without sending the user's
password again. It is a long-lived credential: protect it as carefully as a password.
These JSON endpoints extend FlexMeasures authentication; they are not an OAuth implementation
and do not change browser login sessions.

To opt in, add ``"include-refresh-token": true`` to ``POST /api/requestAuthToken``:

.. code-block:: json

{
"email": "user@example.com",
"password": "<user password>",
"include-refresh-token": true
}

A successful response has HTTP status 200:

.. code-block:: json

{
"auth_token": "<access token>",
"user_id": 1,
"refresh-token": "<refresh token>",
"expires-in": 21600
}

``auth_token`` is the access token used for ordinary API requests. ``expires-in`` is its
lifetime in seconds, reflecting the host's configuration. The example uses the default
six hours. A refresh token cannot authenticate an ordinary API request.
If the host has configured access tokens without an expiry, ``expires-in`` is ``null``.
Responses also include FlexMeasures' usual ``status`` field.

To replace the access token, send ``POST /api/refreshAuthToken`` with this JSON body:

.. code-block:: json

{"refresh-token": "<current refresh token>"}

No password or access token is required, so refresh also works after the access token
expires. A successful refresh returns the same four fields as the opt-in login response,
including a replacement refresh token. Save both replacement tokens together before making
another refresh request.

Each refresh token can be used only once. All replacement tokens descended from a login
form a *token family*. Reusing an already exchanged token revokes that family, including
its newest refresh token. Serialize refresh requests across workers that share credentials.
If a refresh response is lost, authenticate again with email and password; blindly retrying
the old token may revoke the family.

To stop future refreshes, send ``POST /api/revokeRefreshToken`` with the current refresh
token in the same JSON format. It returns HTTP status 200 with ``{"revoked": true}``
and revokes the family. Already issued access tokens remain usable until they expire or
are invalidated, for example by a password change. Disabled users cannot authenticate.
Password changes and user deactivation revoke existing refresh-token families.
Reactivating a user does not restore those families. Revocation does not log out
an existing browser session.

By default, a refresh-token family expires 90 days after its initial login, or after
7 days without a successful refresh, whichever happens first. Rotation does not extend
the absolute lifetime. Hosts configure these limits with ``SECURITY_REFRESH_TOKEN_MAX_AGE``
and ``SECURITY_REFRESH_TOKEN_MAX_IDLE``. After expiry or revocation, authenticate again
with email and password.
These settings take Python ``datetime.timedelta`` values. API refresh support does not
require enabling Flask-Security's global ``SECURITY_REFRESH_TOKEN`` flag, which also
changes browser authentication. Apply the database migration before running the upgraded server.

Malformed requests return HTTP status 400 with validation errors. An invalid, expired,
revoked, or reused refresh token returns HTTP status 401 with ``error`` set to
``"invalid_refresh_token"`` and an ``errors`` list. On that response, authenticate again;
do not retry the rejected token. Ordinary API authorization still depends on the user's
permissions, and refreshing a token does not grant additional permissions.

Use HTTPS, keep passwords and tokens out of logs, and store them in a protected credential
store. Token responses include ``Cache-Control: no-store``. The following examples use
placeholders; do not put real credentials in shell history.

Example requests with curl (``--fail-with-body`` reports HTTP failures):

.. code-block:: bash

curl --fail-with-body https://company.flexmeasures.io/api/requestAuthToken \
-H 'Content-Type: application/json' \
-d '{"email":"user@example.com","password":"<password>","include-refresh-token":true}'

curl --fail-with-body https://company.flexmeasures.io/api/v3_0/users/1 \
-H 'Authorization: <access token>'

curl --fail-with-body https://company.flexmeasures.io/api/refreshAuthToken \
-H 'Content-Type: application/json' \
-d '{"refresh-token":"<current refresh token>"}'

curl --fail-with-body https://company.flexmeasures.io/api/revokeRefreshToken \
-H 'Content-Type: application/json' \
-d '{"refresh-token":"<replacement refresh token>"}'

The equivalent Python sequence logs in, accesses the API, refreshes, accesses the API
with the replacement token, and revokes the refresh-token family. This single-process
example keeps tokens in memory; a persistent client should save replacements atomically.
It deliberately does not automatically retry failed refresh requests.

.. code-block:: python

import getpass
import requests

base_url = "https://company.flexmeasures.io"

def post_token(endpoint, body):
response = requests.post(
f"{base_url}/api/{endpoint}", json=body, timeout=30
)
if response.status_code == 401:
raise RuntimeError("Authentication failed; sign in again.")
response.raise_for_status()
return response.json()

tokens = post_token("requestAuthToken", {
"email": input("Email: "),
"password": getpass.getpass(),
"include-refresh-token": True,
})

def get_user(tokens):
response = requests.get(
f"{base_url}/api/v3_0/users/{tokens['user_id']}",
headers={"Authorization": tokens["auth_token"]},
timeout=30,
)
response.raise_for_status()
return response.json()

user = get_user(tokens)
tokens = post_token("refreshAuthToken", {
"refresh-token": tokens["refresh-token"],
})
user = get_user(tokens)
post_token("revokeRefreshToken", {
"refresh-token": tokens["refresh-token"],
})

.. _api_see_other:

Expand Down
2 changes: 2 additions & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ v1.2.0 | December xx, 2026
New features
-------------

* Add opt-in rotating refresh tokens for API clients, with expiry and revocation while preserving existing access-token authentication [see `issue #2229 <https://github.com/FlexMeasures/flexmeasures/issues/2229>`_]

Infrastructure / Support
-------------------------
* Speed up preparing a schedule for a flex-model with many devices, such as one device per charging session, by reading each commitment's constant values once instead of off each of its thousands of one-row sub-commitments [see `PR #2516 <https://www.github.com/FlexMeasures/flexmeasures/pull/2516>`_]
Expand Down
2 changes: 1 addition & 1 deletion documentation/concepts/security_auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Authentication
This involves a username/password combination ("credentials") or an access token.

* No user passwords are stored in clear text on any server - the FlexMeasures platform only stores the hashed passwords (encrypted with the `bcrypt hashing algorithm <https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html>`_). If an attacker steals these password hashes, they cannot compute the passwords from them in a practical amount of time.
* In the API, access tokens are used so that the sending of usernames and passwords is limited (even if they are encrypted via https, see above) when dealing with the part of the FlexMeasures platform which sees the most traffic: the API functionality. Tokens thus have use cases for some scenarios, where developers want to treat authentication information with a little less care than credentials should be treated with, e.g. sharing among computers. However, they also expire fast, which is a common industry practice (by making them short-lived and requiring refresh, FlexMeasures limits the time an attacker can abuse a stolen token). At the moment, the access tokens on FlexMeasures platform expire after six hours. Access tokens are encrypted and validated with the `sha256_crypt algorithm <https://passlib.readthedocs.io/en/stable/lib/passlib.hash.sha256_crypt.html>`_, and `the functionality to expire tokens is realised by storing the seconds since January 1, 2011 in the token <https://pythonhosted.org/itsdangerous/#itsdangerous.TimestampSigner>`_. The maximum age of access tokens in FlexMeasures can be altered by setting the env variable `SECURITY_TOKEN_MAX_AGE` to the number of seconds after which tokens should expire.
* In the API, access tokens limit how often clients need to send a username and password. Treat tokens as secrets and transmit them only over HTTPS. Access tokens expire after six hours by default; hosts can configure their lifetime with ``SECURITY_TOKEN_MAX_AGE`` (seconds). Clients can opt in to rotating refresh tokens to obtain replacement access tokens without resending the password. Refresh tokens are longer-lived credentials and need the same protection as passwords. See :ref:`api_auth` for the existing login flow and :ref:`api_refresh_tokens` for expiry, rotation, revocation, and client examples.
* In the UI, FlexMeasures uses two-factor authentication (2FA). This means that the knowledge of the password to your FlexMeasures account is not sufficient to gain access ― you also need a second piece of knowledge, which requires you to also have access to an independent system or device (e.g. a token sent to your email address).

.. note:: Authentication (and authorization, see below) affects the FlexMeasures API and UI. The CLI (command line interface) can only be used if the user is already on the server and can execute ``flexmeasures`` commands, thus we can safely assume they are admins.
Expand Down
44 changes: 40 additions & 4 deletions flexmeasures/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from flask_json import as_json
from flask_login import current_user
from webargs.flaskparser import use_args
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy import select

from flexmeasures.data import db
Expand Down Expand Up @@ -59,10 +59,19 @@ def request_auth_token(args) -> tuple[dict, int]:
schema:
type: object
properties:
authentication_token:
auth_token:
type: string
description: |
Short-lived authentication token, which can be used in request headers using `{"Authorization": authentication_token}`.
Short-lived access token, used as `{"Authorization": auth_token}`.
user_id:
type: integer
refresh-token:
type: string
description: Returned only when include-refresh-token is true.
expires-in:
type: integer
nullable: true
description: Access-token lifetime in seconds (null if unlimited), returned with refresh-token.
400:
description: INVALID_REQUEST
401:
Expand Down Expand Up @@ -97,9 +106,27 @@ def request_auth_token(args) -> tuple[dict, int]:
return {"errors": ["Please provide the 'password' parameter."]}, 400
if not verify_password(request.json["password"], user.password):
return {"errors": ["User password does not match."]}, 401
if args["include-refresh-token"]:
# Serialize issuance with deactivation/password changes, then verify
# credentials again against the locked, up-to-date user record.
user = db.session.scalar(
select(User)
.where(User.id == user.id)
.with_for_update()
.execution_options(populate_existing=True)
)
if not user or not verify_password(args["password"], user.password):
return {"errors": ["User password does not match."]}, 401
if not user.active:
return {"errors": ["User is inactive."]}, 401
if args["include-refresh-token"]:
from flexmeasures.auth.tokens import issue_refresh_token

return issue_refresh_token(user)
token = user.get_auth_token()
return {"auth_token": token, "user_id": user.id}
except Exception as e:
except SQLAlchemyError as e:
db.session.rollback()
current_app.logger.error(f"Exception in /requestAuthToken endpoint: {e}")
return {"errors": ["An internal error has occurred."]}, 500

Expand Down Expand Up @@ -166,13 +193,22 @@ def register_at(app: Flask):

rate_limiting.register_at(app)

from flexmeasures.api import refresh_tokens # noqa: F401

app.register_blueprint(
flexmeasures_api, url_prefix="/api"
) # now registering the blueprint will affect all endpoints

# Add version headers to all API responses
@app.after_request
def add_version_headers(response):
if request.endpoint in {
"flexmeasures_api.request_auth_token",
"flexmeasures_api.refresh_auth_token",
"flexmeasures_api.revoke_refresh_token",
}:
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
if request.path.startswith("/api/"):
response.headers["FlexMeasures-Version"] = flexmeasures_version
if request.path.startswith("/api/v3_0"):
Expand Down
111 changes: 111 additions & 0 deletions flexmeasures/api/refresh_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Refresh-token endpoints. These accept JSON credentials, never browser cookies."""

from flask_json import as_json
from marshmallow import Schema, fields, validate
from webargs.flaskparser import use_args

from flexmeasures.api import flexmeasures_api
from flexmeasures.auth.tokens import exchange_refresh_token


class RefreshTokenRequestSchema(Schema):
refresh_token = fields.Str(
data_key="refresh-token",
attribute="refresh-token",
required=True,
validate=validate.Length(min=1),
metadata={
"description": "The current refresh token.",
"example": "<refresh token>",
},
)


@flexmeasures_api.route("/refreshAuthToken", methods=["POST"])
@use_args(RefreshTokenRequestSchema, location="json")
@as_json
def refresh_auth_token(args):
"""
.. :quickref: Auth; Exchange a refresh token
---
post:
summary: Rotate a refresh token and obtain a new access token.
description: |
No access token or password is required. Save the returned refresh token
atomically and serialize refresh requests. Reuse revokes the token family.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [refresh-token]
properties:
refresh-token:
type: string
responses:
200:
description: New access and refresh token pair.
content:
application/json:
schema:
type: object
properties:
auth_token:
type: string
user_id:
type: integer
refresh-token:
type: string
expires-in:
type: integer
nullable: true
description: Access-token lifetime in seconds, or null if unlimited.
400:
description: Invalid request.
401:
description: Invalid, expired or revoked refresh token. Authenticate again.
tags:
- Auth
"""
return exchange_refresh_token(args["refresh-token"])


@flexmeasures_api.route("/revokeRefreshToken", methods=["POST"])
@use_args(RefreshTokenRequestSchema, location="json")
@as_json
def revoke_refresh_token(args):
"""
.. :quickref: Auth; Revoke a refresh-token family
---
post:
summary: Revoke this refresh-token family.
description: Existing access tokens remain valid until expiry or invalidation.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [refresh-token]
properties:
refresh-token:
type: string
responses:
200:
description: Refresh-token family revoked.
content:
application/json:
schema:
type: object
properties:
revoked:
type: boolean
400:
description: Invalid request.
401:
description: Invalid, expired or revoked refresh token.
tags:
- Auth
"""
return exchange_refresh_token(args["refresh-token"], revoke=True)
Loading
Loading