From 836ae8d9374c2d5b07a3c39ee449ee69660906d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Sun, 8 Nov 2026 00:18:15 +0100 Subject: [PATCH 1/5] auth: persist refresh-token families and serialize lifecycle changes Context: - Refresh-token rotation needs durable state and protection against concurrent use. Change: - Add the tracker model and migration, lock user and family rows consistently, and revoke families on credential changes or deactivation. --- flexmeasures/auth/__init__.py | 11 +++- flexmeasures/auth/datastore.py | 33 +++++++++++ ...af36c80d214e_add_refresh_token_trackers.py | 45 +++++++++++++++ flexmeasures/data/models/refresh_tokens.py | 57 +++++++++++++++++++ 4 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 flexmeasures/auth/datastore.py create mode 100644 flexmeasures/data/migrations/versions/af36c80d214e_add_refresh_token_trackers.py create mode 100644 flexmeasures/data/models/refresh_tokens.py diff --git a/flexmeasures/auth/__init__.py b/flexmeasures/auth/__init__.py index 946fb81aa5..73a4876dd0 100644 --- a/flexmeasures/auth/__init__.py +++ b/flexmeasures/auth/__init__.py @@ -5,7 +5,6 @@ from flask import Flask from flask_security import ( Security, - SQLAlchemySessionUserDatastore, password_reset, password_changed, reset_password_instructions_sent, @@ -42,7 +41,15 @@ def register_at(app: Flask): FlexMeasuresForgotPasswordForm, ) - user_datastore = SQLAlchemySessionUserDatastore(db.session, User, Role) + from flexmeasures.auth.datastore import FlexMeasuresUserDatastore + from flexmeasures.data.models.refresh_tokens import RefreshTokenTracker + + user_datastore = FlexMeasuresUserDatastore( + db.session, User, Role, refresh_tracker_model=RefreshTokenTracker + ) + # API token issuance opts in explicitly and uses the tracker helpers directly. + # Keep Flask-Security's global REFRESH_TOKEN flag at its default: enabling it + # also changes browser login/logout and exposes its own refresh endpoint. app.security = Security( app, user_datastore, diff --git a/flexmeasures/auth/datastore.py b/flexmeasures/auth/datastore.py new file mode 100644 index 0000000000..55f4397381 --- /dev/null +++ b/flexmeasures/auth/datastore.py @@ -0,0 +1,33 @@ +"""Serialize validation and rotation of a refresh-token family.""" + +from flask_security import SQLAlchemySessionUserDatastore +from sqlalchemy import select + +from flexmeasures.data.models.refresh_tokens import RefreshTokenTracker +from flexmeasures.data.models.user import User + + +class FlexMeasuresUserDatastore(SQLAlchemySessionUserDatastore): + def find_refresh_tracker(self, refresh_family: str) -> RefreshTokenTracker | None: + user_id = self.db.session.scalar( + select(RefreshTokenTracker.user_id).where( + RefreshTokenTracker.refresh_family == refresh_family + ) + ) + if user_id is None: + return None + # Always lock user before family, matching credential changes and issuance. + self.db.session.scalar( + select(User) + .where(User.id == user_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + # Hold the lock until the endpoint commits, including reuse revocation. + # Refresh ORM state too: another transaction may have rotated this family. + return self.db.session.scalar( + select(RefreshTokenTracker) + .where(RefreshTokenTracker.refresh_family == refresh_family) + .with_for_update() + .execution_options(populate_existing=True) + ) diff --git a/flexmeasures/data/migrations/versions/af36c80d214e_add_refresh_token_trackers.py b/flexmeasures/data/migrations/versions/af36c80d214e_add_refresh_token_trackers.py new file mode 100644 index 0000000000..8c63d866f7 --- /dev/null +++ b/flexmeasures/data/migrations/versions/af36c80d214e_add_refresh_token_trackers.py @@ -0,0 +1,45 @@ +"""Add refresh-token family tracking. + +Revision ID: af36c80d214e +Revises: bf22b21d0990 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "af36c80d214e" +down_revision = "bf22b21d0990" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "refresh_token_tracker", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("fm_user.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("refresh_family", sa.String(64), nullable=False, unique=True), + sa.Column("gen", sa.Integer(), nullable=False), + sa.Column("name", sa.String(64), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("last_used_at", sa.DateTime(), nullable=False), + sa.Column("revoked_at", sa.DateTime()), + sa.Column( + "create_datetime", + sa.DateTime(), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index( + "ix_refresh_token_tracker_user_id", "refresh_token_tracker", ["user_id"] + ) + + +def downgrade(): + op.drop_table("refresh_token_tracker") diff --git a/flexmeasures/data/models/refresh_tokens.py b/flexmeasures/data/models/refresh_tokens.py new file mode 100644 index 0000000000..2ba4f94431 --- /dev/null +++ b/flexmeasures/data/models/refresh_tokens.py @@ -0,0 +1,57 @@ +"""Persistent refresh-token families, using Flask-Security's rotation protocol.""" + +from flask_security import RefreshTrackerMixin +from sqlalchemy import event, inspect + +from flexmeasures.data import db +from flexmeasures.data.models.user import User + + +class RefreshTokenTracker(db.Model, RefreshTrackerMixin): + __tablename__ = "refresh_token_tracker" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.Integer, + db.ForeignKey("fm_user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + refresh_family = db.Column(db.String(64), unique=True, nullable=False) + gen = db.Column(db.Integer, nullable=False) + name = db.Column(db.String(64), nullable=False) + # Flask-Security's datetime_factory uses naive UTC datetimes. + expires_at = db.Column(db.DateTime, nullable=False) + last_used_at = db.Column(db.DateTime, nullable=False) + revoked_at = db.Column(db.DateTime) + create_datetime = db.Column( + db.DateTime, nullable=False, server_default=db.func.now() + ) + user = db.relationship( + User, + backref=db.backref( + "refresh_trackers", + cascade="all, delete-orphan", + order_by="RefreshTokenTracker.id", + ), + ) + + +@event.listens_for(User, "after_update") +def revoke_refresh_tokens_on_identity_change(mapper, connection, user): + """Do not revive refresh access when a disabled user is reactivated.""" + # The user UPDATE already holds the user-row lock. Token endpoints acquire + # this same lock before touching families, so issuance cannot race this sweep. + state = inspect(user) + disabled = state.attrs.active.history.has_changes() and not user.active + credentials_changed = any( + state.attrs[field].history.has_changes() + for field in ("password", "fs_uniquifier") + ) + if disabled or credentials_changed: + connection.execute( + RefreshTokenTracker.__table__.update() + .where(RefreshTokenTracker.user_id == user.id) + .where(RefreshTokenTracker.revoked_at.is_(None)) + .values(revoked_at=db.func.timezone("UTC", db.func.now())) + ) From 39b2425b6cc025c684d30ac449b7819ab3a56bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Sun, 8 Nov 2026 00:19:09 +0100 Subject: [PATCH 2/5] api: add opt-in refresh-token issuance, rotation and revocation Context: - Integrations need renewable credentials without changing existing access-token clients. Change: - Add JSON refresh and revocation endpoints with kebab-case fields, lifetime reporting, replay protection and immediate revocation. - Preserve legacy response fields and token lifetime; reject inactive users and prevent caching token responses. --- flexmeasures/api/__init__.py | 44 ++++++++++-- flexmeasures/api/refresh_tokens.py | 111 +++++++++++++++++++++++++++++ flexmeasures/api/v3_0/users.py | 9 +++ flexmeasures/auth/tokens.py | 84 ++++++++++++++++++++++ 4 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 flexmeasures/api/refresh_tokens.py create mode 100644 flexmeasures/auth/tokens.py diff --git a/flexmeasures/api/__init__.py b/flexmeasures/api/__init__.py index 564b1f33a8..a320c62d82 100644 --- a/flexmeasures/api/__init__.py +++ b/flexmeasures/api/__init__.py @@ -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 @@ -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: @@ -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 @@ -166,6 +193,8 @@ 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 @@ -173,6 +202,13 @@ def register_at(app: Flask): # 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"): diff --git a/flexmeasures/api/refresh_tokens.py b/flexmeasures/api/refresh_tokens.py new file mode 100644 index 0000000000..b41c1dadb1 --- /dev/null +++ b/flexmeasures/api/refresh_tokens.py @@ -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": "", + }, + ) + + +@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) diff --git a/flexmeasures/api/v3_0/users.py b/flexmeasures/api/v3_0/users.py index a1a0871483..eedefdcfed 100644 --- a/flexmeasures/api/v3_0/users.py +++ b/flexmeasures/api/v3_0/users.py @@ -42,6 +42,15 @@ class AuthRequestSchema(Schema): + include_refresh_token = fields.Bool( + data_key="include-refresh-token", + attribute="include-refresh-token", + load_default=False, + metadata={ + "description": "Also issue a rotating refresh token and access-token expires-in (seconds).", + "example": True, + }, + ) email = fields.Str( required=True, metadata={"description": "The user's email address."} ) diff --git a/flexmeasures/auth/tokens.py b/flexmeasures/auth/tokens.py new file mode 100644 index 0000000000..215f459e8c --- /dev/null +++ b/flexmeasures/auth/tokens.py @@ -0,0 +1,84 @@ +"""JSON API token lifecycle backed by Flask-Security refresh trackers.""" + +import time + +from flask import current_app +from flask_security.tokens import ( + new_refresh_tracker, + get_refresh_token, + verify_refresh_token, + RefreshTokenErrors, +) +from flask_security.signals import refresh_tracker_revoked + +from flexmeasures.data import db + + +def token_response(user, refresh_token: str) -> dict: + """Report the effective lifetime, including a deployment's per-token expiry.""" + security = current_app.extensions["security"] + access_token = user.get_auth_token() + data, issued_at = security.remember_token_serializer.loads( + access_token, return_timestamp=True + ) + max_age = current_app.config["SECURITY_TOKEN_MAX_AGE"].total_seconds() + deadlines = [] + if max_age: + deadlines.append(issued_at.timestamp() + max_age) + if data.get("exp"): + deadlines.append(data["exp"]) + # None explicitly denotes a deployment configured with non-expiring tokens. + expires_in = max(0, int(min(deadlines) - time.time())) if deadlines else None + return { + "auth_token": access_token, + "user_id": user.id, + "refresh-token": refresh_token, + "expires-in": expires_in, + } + + +def issue_refresh_token(user) -> dict: + """Caller must hold the user lock and have verified their credentials.""" + _, refresh_token = new_refresh_tracker(user, name="api") + payload = token_response(user, refresh_token) + db.session.commit() + return payload + + +def exchange_refresh_token(token: str, *, revoke: bool = False) -> tuple[dict, int]: + """Validate, rotate or revoke within one transaction holding the family lock.""" + security = current_app.extensions["security"] + datastore = security.datastore + tracker, error, uid = verify_refresh_token(token) + # Revocation is immediate. Upstream compares revoked_at < now, which permits + # reuse at the same clock instant (or when worker clocks differ slightly). + if tracker is not None and tracker.revoked_at is not None: + error = RefreshTokenErrors.REVOKED + user = ( + datastore.find_user(**{datastore.get_token_uniquifier_name(): uid}) + if uid + else None + ) + if tracker is not None and (revoke or error == RefreshTokenErrors.GEN_MISMATCH): + datastore.revoke_refresh_tracker(tracker) + refresh_tracker_revoked.send( + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, + user=user, + refresh_tracker=tracker, + refresh_errors=error, + ) + if error or user is None or not user.active: + # Commit replay revocation even though authentication failed. + db.session.commit() + return { + "error": "invalid_refresh_token", + "errors": ["Refresh token is invalid or expired. Authenticate again."], + }, 401 + if revoke: + db.session.commit() + return {"revoked": True}, 200 + datastore.exchange_refresh_tracker(tracker) + payload = token_response(user, get_refresh_token(tracker, user)) + db.session.commit() + return payload, 200 From 3c62aa9fae16f7d0e93b97bb7ff9bcb40b023d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Sun, 8 Nov 2026 00:20:38 +0100 Subject: [PATCH 3/5] tests: cover refresh-token security and legacy authentication Context: - Authentication lifecycle changes need regression coverage using real token validation. Change: - Cover rotation, concurrent reuse, expiry, revocation and credential changes. - Correct naming checks for the existing auth_token and user_id response contract without allowing new snake-case fields. --- .../api/tests/test_refresh_tokens_fresh_db.py | 321 ++++++++++++++++++ .../data/schemas/tests/test_field_naming.py | 16 +- 2 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 flexmeasures/api/tests/test_refresh_tokens_fresh_db.py diff --git a/flexmeasures/api/tests/test_refresh_tokens_fresh_db.py b/flexmeasures/api/tests/test_refresh_tokens_fresh_db.py new file mode 100644 index 0000000000..ddbc7e6d24 --- /dev/null +++ b/flexmeasures/api/tests/test_refresh_tokens_fresh_db.py @@ -0,0 +1,321 @@ +"""Exercise token lifecycle endpoints with real Flask-Security authentication.""" + +from concurrent.futures import ThreadPoolExecutor +from datetime import timedelta +from threading import Barrier +import time + +import pytest +from flask import g, url_for +from sqlalchemy import select + +from flexmeasures.data.models.user import User + + +@pytest.fixture(autouse=True) +def patch_check_token(): + """Use real token validation, including expiry and inactive-user checks.""" + + +@pytest.fixture +def token_user(fresh_db, setup_roles_users_fresh_db): + user = fresh_db.session.scalar( + select(User).filter_by(email="test_admin_user@seita.nl") + ) + fresh_db.session.commit() + return user + + +def login(client, **kwargs): + if "include_refresh_token" in kwargs: + kwargs["include-refresh-token"] = kwargs.pop("include_refresh_token") + return client.post( + "/api/requestAuthToken", + json={"email": "test_admin_user@seita.nl", "password": "testtest", **kwargs}, + ) + + +def refresh(client, token): + return client.post("/api/refreshAuthToken", json={"refresh-token": token}) + + +def clear_auth(): + # The session-scoped app context otherwise caches authentication across requests. + for key in ("_login_user", "fs_authn_via", "fs_paa", "csrf_valid"): + g.pop(key, None) + + +def assert_pair(response, user_id): + assert response.status_code == 200, response.json + assert { + "auth_token", + "user_id", + "refresh-token", + "expires-in", + } <= response.json.keys() + assert not {"refresh_token", "expires_in"} & response.json.keys() + assert response.json["user_id"] == user_id + assert isinstance(response.json["auth_token"], str) + assert isinstance(response.json["refresh-token"], str) + assert response.json["auth_token"] != response.json["refresh-token"] + assert isinstance(response.json["expires-in"], int) + assert response.json["expires-in"] > 0 + assert "no-store" in response.headers["Cache-Control"] + return response.json + + +@pytest.mark.parametrize("options", [{}, {"include_refresh_token": False}]) +def test_legacy_token_response(client, token_user, options): + response = login(client, **options) + assert response.status_code == 200 + assert set(response.json) == {"auth_token", "user_id", "status"} + assert response.json["user_id"] == token_user.id + clear_auth() + response = client.get( + url_for("AssetAPI:index"), + headers={"Authorization": response.json["auth_token"]}, + ) + assert response.status_code == 200 + + +def test_issue_and_rotate_tokens(client, token_user): + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + rotated = assert_pair(refresh(client, pair["refresh-token"]), token_user.id) + assert rotated["refresh-token"] != pair["refresh-token"] + clear_auth() + assert ( + client.get( + url_for("AssetAPI:index"), headers={"Authorization": rotated["auth_token"]} + ).status_code + == 200 + ) + + +def test_refresh_token_cannot_authenticate_api_request(client, token_user): + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + clear_auth() + assert ( + client.get( + url_for("AssetAPI:index"), headers={"Authorization": pair["refresh-token"]} + ).status_code + == 401 + ) + + +def test_access_token_cannot_be_refreshed(client, token_user): + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + assert refresh(client, pair["auth_token"]).status_code == 401 + + +def test_reuse_revokes_rotated_token_family(client, token_user): + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + rotated = assert_pair(refresh(client, pair["refresh-token"]), token_user.id) + assert refresh(client, pair["refresh-token"]).status_code == 401 + assert refresh(client, rotated["refresh-token"]).status_code == 401 + + +def test_revoke_refresh_token(client, token_user): + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + response = client.post( + "/api/revokeRefreshToken", json={"refresh-token": pair["refresh-token"]} + ) + assert response.status_code == 200 + assert refresh(client, pair["refresh-token"]).status_code == 401 + + +@pytest.mark.parametrize("include_refresh_token", [False, True]) +def test_disabled_user_cannot_obtain_tokens( + client, fresh_db, token_user, include_refresh_token +): + token_user.active = False + fresh_db.session.commit() + options = {"include_refresh_token": True} if include_refresh_token else {} + assert login(client, **options).status_code == 401 + + +def test_disabled_user_cannot_refresh(client, fresh_db, token_user): + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + token_user.active = False + fresh_db.session.commit() + assert refresh(client, pair["refresh-token"]).status_code == 401 + token_user.active = True + fresh_db.session.commit() + assert refresh(client, pair["refresh-token"]).status_code == 401 + + +@pytest.mark.parametrize("path", ["/api/refreshAuthToken", "/api/revokeRefreshToken"]) +@pytest.mark.parametrize( + "payload", [{}, {"refresh-token": None}, {"refresh-token": 12}] +) +def test_malformed_refresh_requests(client, token_user, path, payload): + assert client.post(path, json=payload).status_code in (400, 422) + + +@pytest.mark.parametrize("path", ["/api/refreshAuthToken", "/api/revokeRefreshToken"]) +def test_invalid_refresh_token(client, token_user, path): + assert client.post(path, json={"refresh-token": "invalid"}).status_code == 401 + + +@pytest.fixture +def token_clock(app, monkeypatch): + """Advance both token signatures and refresh tracker clocks together.""" + security = app.extensions["security"] + initial = security.datetime_factory() + wall_time = time.time() + + def advance(seconds): + monkeypatch.setattr( + security, "datetime_factory", lambda: initial + timedelta(seconds=seconds) + ) + monkeypatch.setattr(time, "time", lambda: wall_time + seconds) + + return advance + + +def test_expired_access_token_can_be_refreshed( + app, client, token_user, token_clock, monkeypatch +): + monkeypatch.setitem(app.config, "SECURITY_TOKEN_MAX_AGE", timedelta(seconds=5)) + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + # Remaining lifetime is rounded down to avoid overstating validity. + assert 0 < pair["expires-in"] <= 5 + token_clock(10) + clear_auth() + assert ( + client.get( + url_for("AssetAPI:index"), headers={"Authorization": pair["auth_token"]} + ).status_code + == 401 + ) + assert_pair(refresh(client, pair["refresh-token"]), token_user.id) + + +def test_refresh_idle_expiry(app, client, token_user, token_clock, monkeypatch): + monkeypatch.setitem( + app.config, "SECURITY_REFRESH_TOKEN_MAX_IDLE", timedelta(seconds=10) + ) + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + token_clock(11) + assert refresh(client, pair["refresh-token"]).status_code == 401 + + +def test_rotation_preserves_absolute_expiry( + app, client, token_user, token_clock, monkeypatch +): + monkeypatch.setitem( + app.config, "SECURITY_REFRESH_TOKEN_MAX_AGE", timedelta(seconds=20) + ) + monkeypatch.setitem( + app.config, "SECURITY_REFRESH_TOKEN_MAX_IDLE", timedelta(seconds=15) + ) + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + token_clock(10) + pair = assert_pair(refresh(client, pair["refresh-token"]), token_user.id) + token_clock(21) + assert refresh(client, pair["refresh-token"]).status_code == 401 + + +def test_independent_login_has_independent_token_family(client, token_user): + first = assert_pair(login(client, include_refresh_token=True), token_user.id) + second = assert_pair(login(client, include_refresh_token=True), token_user.id) + assert first["refresh-token"] != second["refresh-token"] + client.post( + "/api/revokeRefreshToken", json={"refresh-token": first["refresh-token"]} + ) + assert_pair(refresh(client, second["refresh-token"]), token_user.id) + + +def test_wrong_password_cannot_obtain_refresh_token(client, fresh_db, token_user): + response = login(client, include_refresh_token=True, password="incorrect") + assert response.status_code == 401 + assert "refresh-token" not in response.json + fresh_db.session.refresh(token_user) + assert not token_user.refresh_trackers + + +def test_changed_token_identity_revokes_refresh(client, fresh_db, token_user): + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + token_user.fs_uniquifier = "changed-token-identity" + fresh_db.session.commit() + assert refresh(client, pair["refresh-token"]).status_code == 401 + + +def test_concurrent_refresh_revokes_family(app, client, token_user): + """Only one racing refresh succeeds, and replay revokes its replacement.""" + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + barrier = Barrier(2) + + def exchange(): + with app.app_context(), app.test_client() as concurrent_client: + barrier.wait(timeout=10) + response = refresh(concurrent_client, pair["refresh-token"]) + return response.status_code, response.json + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(exchange) for _ in range(2)] + responses = [future.result(timeout=20) for future in futures] + assert sorted(status for status, _ in responses) == [200, 401] + replacement = next( + body["refresh-token"] for status, body in responses if status == 200 + ) + assert refresh(client, replacement).status_code == 401 + + +@pytest.mark.parametrize("change", ["password", "deactivate"]) +def test_security_change_revokes_without_refresh_attempt( + client, fresh_db, token_user, change +): + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + if change == "password": + token_user.password = "changed-password-hash" + else: + token_user.active = False + fresh_db.session.commit() + if change == "deactivate": + token_user.active = True + fresh_db.session.commit() + assert refresh(client, pair["refresh-token"]).status_code == 401 + + +def test_per_token_expiry_limits_reported_lifetime( + app, client, token_user, monkeypatch +): + monkeypatch.setitem( + app.config, "SECURITY_TOKEN_EXPIRE_TIMESTAMP", lambda user: time.time() + 30 + ) + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + assert 0 < pair["expires-in"] <= 30 + + +def test_unlimited_access_token_lifetime(app, client, token_user, monkeypatch): + monkeypatch.setitem(app.config, "SECURITY_TOKEN_MAX_AGE", timedelta(0)) + monkeypatch.setitem(app.config, "SECURITY_TOKEN_EXPIRE_TIMESTAMP", lambda user: 0) + response = login(client, include_refresh_token=True) + assert response.status_code == 200 + assert response.json["expires-in"] is None + + +def test_revocation_at_same_instant(client, token_user, token_clock): + """Revocation is immediate, even when two requests share a clock timestamp.""" + token_clock(0) + pair = assert_pair(login(client, include_refresh_token=True), token_user.id) + response = client.post( + "/api/revokeRefreshToken", json={"refresh-token": pair["refresh-token"]} + ) + assert response.status_code == 200 + assert response.json["revoked"] is True + assert refresh(client, pair["refresh-token"]).status_code == 401 + + +def test_session_cannot_replace_password_for_refresh_issuance(client, token_user): + with client.session_transaction() as session: + session["_user_id"] = token_user.get_id() + session["_fresh"] = True + clear_auth() + # Preserve the existing signed-in access-token flow, but require the password + # before issuing a new long-lived refresh credential. + assert login(client, password="incorrect").status_code == 200 + clear_auth() + response = login(client, password="incorrect", include_refresh_token=True) + assert response.status_code == 401 + assert "refresh-token" not in response.json diff --git a/flexmeasures/data/schemas/tests/test_field_naming.py b/flexmeasures/data/schemas/tests/test_field_naming.py index b01f84a77d..82d82c59df 100644 --- a/flexmeasures/data/schemas/tests/test_field_naming.py +++ b/flexmeasures/data/schemas/tests/test_field_naming.py @@ -31,7 +31,6 @@ "account_id", "account_roles", "active_user_id", - "authentication_token", "all_accessible", "asset_type", "belief_horizon", @@ -111,6 +110,15 @@ } ) +# requestAuthToken has always returned these keys; its old OpenAPI description +# incorrectly called the token authentication_token and omitted user_id. Keep +# the real legacy response contract (also used by refreshAuthToken), without +# allowing these spellings on new domain APIs or expanding the global debt list. +LEGACY_AUTH_RESPONSE_FIELDS = { + "auth_token": {"/api/requestAuthToken", "/api/refreshAuthToken"}, + "user_id": {"/api/requestAuthToken", "/api/refreshAuthToken"}, +} + def _flexmeasures_schemas() -> list[type[Schema]]: """Every Marshmallow schema FlexMeasures defines, found by importing and walking subclasses.""" @@ -373,9 +381,11 @@ def test_a_body_property_in_the_openapi_specs_is_kebab_case(): This catches a response field documented on its endpoint, whose payload is built as a plain dict rather than dumped by a schema. """ offenders = { - name: sorted(places) + name: sorted(places - LEGACY_AUTH_RESPONSE_FIELDS.get(name, set())) for name, places in _openapi_property_names(_openapi_specs()).items() - if "_" in name and name not in KNOWN_SNAKE_CASE_WIRE_NAMES + if "_" in name + and name not in KNOWN_SNAKE_CASE_WIRE_NAMES + and places - LEGACY_AUTH_RESPONSE_FIELDS.get(name, set()) } assert not offenders, ( From 64a6695de274f4f9e54aaef1dbcccc8419e88243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Sun, 8 Nov 2026 00:21:18 +0100 Subject: [PATCH 4/5] changelog: announce optional API refresh tokens Context: - Issue #2229 adds renewable API credentials. Change: - Describe refresh-token rotation, expiry and revocation while retaining legacy access-token authentication. --- documentation/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index b51fcfe824..553fccc5e8 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -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 `_] + 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 `_] From df93c491ba1b5ad8bdf0bb39100dd38003806518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Sun, 8 Nov 2026 00:22:05 +0100 Subject: [PATCH 5/5] docs: explain the refresh-token lifecycle and API contract Context: - New clients need a complete flow and guidance for safe rotation and recovery. Change: - Document opt-in authentication, exchange, revocation, expiry and deployment requirements with curl and Python examples. - Update generated OpenAPI definitions to match the actual authentication responses. --- documentation/api/introduction.rst | 150 +++++++++++++++++++++- documentation/concepts/security_auth.rst | 2 +- flexmeasures/ui/static/openapi-specs.json | 141 +++++++++++++++++++- 3 files changed, 288 insertions(+), 5 deletions(-) diff --git a/documentation/api/introduction.rst b/documentation/api/introduction.rst index 328dc06e05..b11273945d 100644 --- a/documentation/api/introduction.rst +++ b/documentation/api/introduction.rst @@ -109,7 +109,155 @@ which gives a response like this if the credentials are correct: "user_id": "" } -.. 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": "", + "include-refresh-token": true + } + +A successful response has HTTP status 200: + +.. code-block:: json + + { + "auth_token": "", + "user_id": 1, + "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": ""} + +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":"","include-refresh-token":true}' + + curl --fail-with-body https://company.flexmeasures.io/api/v3_0/users/1 \ + -H 'Authorization: ' + + curl --fail-with-body https://company.flexmeasures.io/api/refreshAuthToken \ + -H 'Content-Type: application/json' \ + -d '{"refresh-token":""}' + + curl --fail-with-body https://company.flexmeasures.io/api/revokeRefreshToken \ + -H 'Content-Type: application/json' \ + -d '{"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: diff --git a/documentation/concepts/security_auth.rst b/documentation/concepts/security_auth.rst index 9630b77423..1620eeced3 100644 --- a/documentation/concepts/security_auth.rst +++ b/documentation/concepts/security_auth.rst @@ -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 `_). 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 `_, and `the functionality to expire tokens is realised by storing the seconds since January 1, 2011 in the token `_. 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. diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 6be8b491e3..84896c9bdf 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "1.1.0" + "version": "1.1.1" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", @@ -36,9 +36,21 @@ "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}`.\n" + "description": "Short-lived access token, used as `{\"Authorization\": auth_token}`.\n" + }, + "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." } } } @@ -103,6 +115,123 @@ ] } }, + "/api/refreshAuthToken": { + "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\natomically and serialize refresh requests. Reuse revokes the token family.\n", + "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." + }, + "429": { + "description": "TOO_MANY_REQUESTS - You called the API more often than your rate limit allows. Wait for as long as the Retry-After header says, then try again." + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/revokeRefreshToken": { + "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." + }, + "429": { + "description": "TOO_MANY_REQUESTS - You called the API more often than your rate limit allows. Wait for as long as the Retry-After header says, then try again." + } + }, + "tags": [ + "Auth" + ] + } + }, "/api/ops/ping": {}, "/api/ops/getLatestTaskRun": {}, "/api/ops/postLatestTaskRun": {}, @@ -7125,6 +7254,12 @@ "AuthRequestSchema": { "type": "object", "properties": { + "include-refresh-token": { + "type": "boolean", + "default": false, + "description": "Also issue a rotating refresh token and access-token expires-in (seconds).", + "example": true + }, "email": { "type": "string", "description": "The user's email address."