From 260c818e32deda4526457da193a691d5a450a83b Mon Sep 17 00:00:00 2001 From: Eli Gold Date: Tue, 31 Mar 2026 02:12:02 +0200 Subject: [PATCH 1/8] feat(api): add scoped persistent access token infrastructure --- ...0b71_add_persistent_access_tokens_table.py | 52 ++++++++ gramps_webapi/api/__init__.py | 7 + gramps_webapi/api/resources/access_tokens.py | 88 +++++++++++++ gramps_webapi/auth/__init__.py | 121 +++++++++++++++++- gramps_webapi/auth/const.py | 6 + 5 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py create mode 100644 gramps_webapi/api/resources/access_tokens.py diff --git a/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py b/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py new file mode 100644 index 00000000..c0d2dc52 --- /dev/null +++ b/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py @@ -0,0 +1,52 @@ +"""add persistent access tokens table + +Revision ID: 6d8f3cb50b71 +Revises: 2082445b0769 +Create Date: 2026-03-31 00:25:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine.reflection import Inspector + +from gramps_webapi.auth.sql_guid import GUID + + +# revision identifiers, used by Alembic. +revision = "6d8f3cb50b71" +down_revision = "2082445b0769" +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + inspector = Inspector.from_engine(conn) + tables = inspector.get_table_names() + if "access_tokens" in tables: + # If table already exists, do nothing + return None + + op.create_table( + "access_tokens", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", GUID(), nullable=False), + sa.Column("scope", sa.String(length=64), nullable=False), + sa.Column("token", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.UniqueConstraint("user_id", "scope", name="uq_access_tokens_user_scope"), + ) + op.create_index("ix_access_tokens_user_id", "access_tokens", ["user_id"], unique=False) + op.create_index("ix_access_tokens_scope", "access_tokens", ["scope"], unique=False) + op.create_index("ix_access_tokens_token", "access_tokens", ["token"], unique=True) + + +def downgrade(): + op.drop_index("ix_access_tokens_token", table_name="access_tokens") + op.drop_index("ix_access_tokens_scope", table_name="access_tokens") + op.drop_index("ix_access_tokens_user_id", table_name="access_tokens") + op.drop_table("access_tokens") diff --git a/gramps_webapi/api/__init__.py b/gramps_webapi/api/__init__.py index 3cc0abe9..5199204c 100644 --- a/gramps_webapi/api/__init__.py +++ b/gramps_webapi/api/__init__.py @@ -34,6 +34,7 @@ BookmarkResource, BookmarksResource, ) +from .resources.access_tokens import UserAccessTokenResource from .resources.chat import ChatResource from .resources.citations import CitationResource, CitationsResource from .resources.config import ConfigResource, ConfigsResource @@ -551,6 +552,12 @@ def register_endpt( # User register_endpt(UsersResource, "/users/", "users", tags=["Users"]) register_endpt(UserResource, "/users//", "user", tags=["Users"]) +register_endpt( + UserAccessTokenResource, + "/users/-/access-tokens//", + "user_access_token", + tags=["Users"], +) register_endpt( UserRegisterResource, "/users//register/", diff --git a/gramps_webapi/api/resources/access_tokens.py b/gramps_webapi/api/resources/access_tokens.py new file mode 100644 index 00000000..746c5029 --- /dev/null +++ b/gramps_webapi/api/resources/access_tokens.py @@ -0,0 +1,88 @@ +# +# Gramps Web API - A RESTful API for the Gramps genealogy program +# +# Copyright (C) 2026 Gramps Web contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# + +"""Persistent access token resources.""" + +from flask_jwt_extended import get_jwt_identity +from marshmallow import Schema +from webargs import fields + +from ...auth import ( + get_name, + get_user_access_token, + normalize_access_token_scope, + revoke_user_access_token, + rotate_user_access_token, +) +from ...auth.const import PERM_EDIT_OWN_USER +from ..auth import require_permissions +from ..blueprint import api_blueprint +from ..util import abort_with_message +from . import ProtectedResource + + +class AccessTokenStateSchema(Schema): + """Response schema for persistent access token state.""" + + active = fields.Boolean( + required=True, + metadata={"description": "Whether a token is currently active."}, + ) + token = fields.Str( + allow_none=True, + metadata={"description": "Persistent token value when active, else null."}, + ) + + +class UserAccessTokenResource(ProtectedResource): + """Resource for managing current user's persistent tokens by scope.""" + + def _get_user_name(self) -> str: + user_id = get_jwt_identity() + try: + return get_name(user_id) + except ValueError: + abort_with_message(401, "User not found for token ID") + raise # unreachable + + def _validate_scope(self, scope: str) -> str: + try: + return normalize_access_token_scope(scope) + except ValueError as exc: + abort_with_message(422, str(exc)) + raise # unreachable + + @api_blueprint.response(200, AccessTokenStateSchema()) + @require_permissions([PERM_EDIT_OWN_USER]) + def get(self, scope: str): + """Get persistent token status for current user and scope.""" + scope = self._validate_scope(scope) + user_name = self._get_user_name() + token = get_user_access_token(user_name, scope) + return {"active": token is not None, "token": token}, 200 + + @api_blueprint.response(200, AccessTokenStateSchema()) + @require_permissions([PERM_EDIT_OWN_USER]) + def post(self, scope: str): + """Create or rotate persistent token for current user and scope.""" + scope = self._validate_scope(scope) + user_name = self._get_user_name() + token = rotate_user_access_token(user_name, scope) + return {"active": True, "token": token}, 200 + + @api_blueprint.response(200, AccessTokenStateSchema()) + @require_permissions([PERM_EDIT_OWN_USER]) + def delete(self, scope: str): + """Revoke persistent token for current user and scope.""" + scope = self._validate_scope(scope) + user_name = self._get_user_name() + revoke_user_access_token(user_name, scope) + return {"active": False, "token": None}, 200 diff --git a/gramps_webapi/auth/__init__.py b/gramps_webapi/auth/__init__.py index 3fd046fe..98c74292 100644 --- a/gramps_webapi/auth/__init__.py +++ b/gramps_webapi/auth/__init__.py @@ -34,7 +34,13 @@ from ..const import DB_CONFIG_ALLOWED_KEYS -from .const import PERMISSIONS, PERM_USE_CHAT, ROLE_ADMIN, ROLE_OWNER +from .const import ( + ACCESS_TOKEN_SCOPES, + PERMISSIONS, + PERM_USE_CHAT, + ROLE_ADMIN, + ROLE_OWNER, +) from .passwords import hash_password, verify_password from .sql_guid import GUID @@ -254,6 +260,88 @@ def get_user_details(username: str) -> Optional[Dict[str, Any]]: return _get_user_detail(user) +def normalize_access_token_scope(scope: str) -> str: + """Validate and normalize a persistent access token scope.""" + normalized_scope = (scope or "").strip().casefold() + if normalized_scope not in ACCESS_TOKEN_SCOPES: + raise ValueError("Unsupported access token scope") + return normalized_scope + + +def get_user_access_token(username: str, scope: str) -> Optional[str]: + """Return persistent access token value for user+scope.""" + scope = normalize_access_token_scope(scope) + query = user_db.session.query(User) # pylint: disable=no-member + user = query.filter_by(name=username).scalar() + if user is None: + raise ValueError("User does not exist") + query = user_db.session.query(AccessToken) # pylint: disable=no-member + access_token = query.filter_by(user_id=user.id, scope=scope).scalar() + if access_token is None: + return None + return access_token.token + + +def rotate_user_access_token(username: str, scope: str) -> str: + """Generate and persist a new token value for user+scope.""" + scope = normalize_access_token_scope(scope) + query = user_db.session.query(User) # pylint: disable=no-member + user = query.filter_by(name=username).scalar() + if user is None: + raise ValueError("User does not exist") + query = user_db.session.query(AccessToken) # pylint: disable=no-member + access_token = query.filter_by(user_id=user.id, scope=scope).scalar() + for _ in range(5): + token = secrets.token_urlsafe(32) + if access_token is None: + access_token = AccessToken(user_id=user.id, scope=scope) + user_db.session.add(access_token) # pylint: disable=no-member + access_token.token = token + access_token.revoked_at = None + access_token.updated_at = datetime.utcnow() + try: + user_db.session.commit() # pylint: disable=no-member + return token + except IntegrityError: + user_db.session.rollback() # pylint: disable=no-member + # Retry if token collided, or if concurrent insert happened. + access_token = query.filter_by(user_id=user.id, scope=scope).scalar() + raise ValueError("Could not generate a unique access token") + + +def revoke_user_access_token(username: str, scope: str) -> None: + """Revoke token value for user+scope.""" + scope = normalize_access_token_scope(scope) + query = user_db.session.query(User) # pylint: disable=no-member + user = query.filter_by(name=username).scalar() + if user is None: + raise ValueError("User does not exist") + query = user_db.session.query(AccessToken) # pylint: disable=no-member + access_token = query.filter_by(user_id=user.id, scope=scope).scalar() + if access_token is None: + return + access_token.token = None + access_token.revoked_at = datetime.utcnow() + access_token.updated_at = datetime.utcnow() + user_db.session.commit() # pylint: disable=no-member + + +def get_user_from_access_token(token: str, scope: str) -> Optional["User"]: + """Return user matching persistent access token value and scope.""" + if not token: + return None + scope = normalize_access_token_scope(scope) + query = user_db.session.query(User) # pylint: disable=no-member + return ( + query.join(AccessToken, AccessToken.user_id == User.id) + .filter( + AccessToken.scope == scope, + AccessToken.token == token, + ) + .scalar() + ) + + def get_all_user_details( tree: Optional[str], include_treeless=False, @@ -545,6 +633,37 @@ def __repr__(self): return f"" +class AccessToken(user_db.Model): # type: ignore + """Persistent user access token table class for sqlalchemy.""" + + __tablename__ = "access_tokens" + + id = mapped_column(sa.Integer, primary_key=True, autoincrement=True) + user_id = mapped_column( + GUID, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + scope = mapped_column(sa.String(64), nullable=False, index=True) + token = mapped_column(sa.String(255), nullable=True, unique=True, index=True) + created_at = mapped_column( + sa.DateTime, nullable=False, server_default=sa.func.now() + ) + updated_at = mapped_column( + sa.DateTime, nullable=False, server_default=sa.func.now() + ) + revoked_at = mapped_column(sa.DateTime, nullable=True) + + __table_args__ = ( + sa.UniqueConstraint("user_id", "scope", name="uq_access_tokens_user_scope"), + ) + + def __repr__(self): + """Return string representation of instance.""" + return ( + f"" + ) + + class Config(user_db.Model): # type: ignore """Config table class for sqlalchemy.""" diff --git a/gramps_webapi/auth/const.py b/gramps_webapi/auth/const.py index 610236c6..f143e9fb 100644 --- a/gramps_webapi/auth/const.py +++ b/gramps_webapi/auth/const.py @@ -135,3 +135,9 @@ SCOPE_CONF_EMAIL = "confirm_email" SCOPE_CREATE_ADMIN = "create_admin" SCOPE_CREATE_OWNER = "create_owner" + +# persistent access token scopes +ACCESS_TOKEN_SCOPE_ANNIVERSARIES_ICS = "anniversaries_ics" +ACCESS_TOKEN_SCOPES = { + ACCESS_TOKEN_SCOPE_ANNIVERSARIES_ICS, +} From 48a708e10500924696f68edfb4245b697e2155c7 Mon Sep 17 00:00:00 2001 From: Eli Gold Date: Tue, 31 Mar 2026 02:12:09 +0200 Subject: [PATCH 2/8] test(api): add persistent access token endpoint tests --- tests/test_endpoints/test_access_tokens.py | 89 ++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/test_endpoints/test_access_tokens.py diff --git a/tests/test_endpoints/test_access_tokens.py b/tests/test_endpoints/test_access_tokens.py new file mode 100644 index 00000000..77607f49 --- /dev/null +++ b/tests/test_endpoints/test_access_tokens.py @@ -0,0 +1,89 @@ +# +# Gramps Web API - A RESTful API for the Gramps genealogy program +# +# Copyright (C) 2026 Gramps Web contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# + +"""Tests for persistent access token endpoints.""" + +import unittest + +from gramps_webapi.auth.const import ROLE_GUEST, ROLE_OWNER + +from . import BASE_URL, get_test_client +from .util import fetch_header + +SCOPE = "anniversaries_ics" +TOKEN_URL = BASE_URL + f"/users/-/access-tokens/{SCOPE}/" + + +class TestAccessTokens(unittest.TestCase): + """Test cases for persistent access token lifecycle endpoints.""" + + @classmethod + def setUpClass(cls): + """Test class setup.""" + cls.client = get_test_client() + + def test_access_token_endpoint_requires_jwt(self): + """Access token endpoint requires authentication.""" + rv = self.client.get(TOKEN_URL) + self.assertEqual(rv.status_code, 401) + + def test_access_token_rejects_invalid_scope(self): + """Access token endpoint rejects unsupported scopes.""" + header = fetch_header(self.client, role=ROLE_OWNER) + rv = self.client.get( + BASE_URL + "/users/-/access-tokens/unsupported-scope/", + headers=header, + ) + self.assertEqual(rv.status_code, 422) + + def test_access_token_lifecycle_owner(self): + """Token lifecycle create/get/rotate/revoke works for owner.""" + header = fetch_header(self.client, role=ROLE_OWNER) + + rv = self.client.get(TOKEN_URL, headers=header) + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.json, {"active": False, "token": None}) + + rv = self.client.post(TOKEN_URL, headers=header) + self.assertEqual(rv.status_code, 200) + token_1 = rv.json["token"] + self.assertTrue(rv.json["active"]) + self.assertIsInstance(token_1, str) + self.assertNotEqual(token_1, "") + + rv = self.client.get(TOKEN_URL, headers=header) + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.json, {"active": True, "token": token_1}) + + rv = self.client.post(TOKEN_URL, headers=header) + self.assertEqual(rv.status_code, 200) + token_2 = rv.json["token"] + self.assertNotEqual(token_1, token_2) + + rv = self.client.get(TOKEN_URL, headers=header) + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.json, {"active": True, "token": token_2}) + + rv = self.client.delete(TOKEN_URL, headers=header) + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.json, {"active": False, "token": None}) + + rv = self.client.get(TOKEN_URL, headers=header) + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.json, {"active": False, "token": None}) + + def test_guest_can_manage_own_access_token(self): + """Guests can manage own token because they can edit own user settings.""" + header = fetch_header(self.client, role=ROLE_GUEST) + rv = self.client.post(TOKEN_URL, headers=header) + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.json["active"], True) + self.assertIsNotNone(rv.json["token"]) From 9641d9405726e460d9e7e71149896b88ac88befa Mon Sep 17 00:00:00 2001 From: Eli Gold Date: Fri, 3 Apr 2026 14:43:23 +0200 Subject: [PATCH 3/8] fix(api): call permission check inside access token handlers --- gramps_webapi/api/resources/access_tokens.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gramps_webapi/api/resources/access_tokens.py b/gramps_webapi/api/resources/access_tokens.py index 746c5029..9d8f5083 100644 --- a/gramps_webapi/api/resources/access_tokens.py +++ b/gramps_webapi/api/resources/access_tokens.py @@ -61,27 +61,27 @@ def _validate_scope(self, scope: str) -> str: raise # unreachable @api_blueprint.response(200, AccessTokenStateSchema()) - @require_permissions([PERM_EDIT_OWN_USER]) def get(self, scope: str): """Get persistent token status for current user and scope.""" + require_permissions([PERM_EDIT_OWN_USER]) scope = self._validate_scope(scope) user_name = self._get_user_name() token = get_user_access_token(user_name, scope) return {"active": token is not None, "token": token}, 200 @api_blueprint.response(200, AccessTokenStateSchema()) - @require_permissions([PERM_EDIT_OWN_USER]) def post(self, scope: str): """Create or rotate persistent token for current user and scope.""" + require_permissions([PERM_EDIT_OWN_USER]) scope = self._validate_scope(scope) user_name = self._get_user_name() token = rotate_user_access_token(user_name, scope) return {"active": True, "token": token}, 200 @api_blueprint.response(200, AccessTokenStateSchema()) - @require_permissions([PERM_EDIT_OWN_USER]) def delete(self, scope: str): """Revoke persistent token for current user and scope.""" + require_permissions([PERM_EDIT_OWN_USER]) scope = self._validate_scope(scope) user_name = self._get_user_name() revoke_user_access_token(user_name, scope) From c24029b189260e19ab9ba1ce6cedfa8a17c4806c Mon Sep 17 00:00:00 2001 From: Eli Gold Date: Sun, 5 Apr 2026 14:44:39 +0200 Subject: [PATCH 4/8] Update __init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- gramps_webapi/auth/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gramps_webapi/auth/__init__.py b/gramps_webapi/auth/__init__.py index 98c74292..545ca999 100644 --- a/gramps_webapi/auth/__init__.py +++ b/gramps_webapi/auth/__init__.py @@ -337,6 +337,8 @@ def get_user_from_access_token(token: str, scope: str) -> Optional["User"]: .filter( AccessToken.scope == scope, AccessToken.token == token, + AccessToken.token.isnot(None), + AccessToken.revoked_at.is_(None), ) .scalar() ) From 77658aafdb890bdf27c7681f6e0afa30e8cc3e50 Mon Sep 17 00:00:00 2001 From: Eli Gold Date: Sun, 5 Apr 2026 14:45:48 +0200 Subject: [PATCH 5/8] Update 6d8f3cb50b71_add_persistent_access_tokens_table.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ...f3cb50b71_add_persistent_access_tokens_table.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py b/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py index c0d2dc52..53485b0f 100644 --- a/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py +++ b/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py @@ -34,8 +34,18 @@ def upgrade(): sa.Column("user_id", GUID(), nullable=False), sa.Column("scope", sa.String(length=64), nullable=False), sa.Column("token", sa.String(length=255), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), sa.Column("revoked_at", sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), sa.UniqueConstraint("user_id", "scope", name="uq_access_tokens_user_scope"), From f1d3eba13ded735a46e4ca70409f4e8acfeca843 Mon Sep 17 00:00:00 2001 From: Eli Gold Date: Sat, 30 May 2026 16:41:21 +0200 Subject: [PATCH 6/8] fix(api): hash persistent access tokens and hide token on GET --- ...0b71_add_persistent_access_tokens_table.py | 8 +++-- gramps_webapi/api/resources/access_tokens.py | 31 ++++++++++------- gramps_webapi/auth/__init__.py | 33 ++++++++++++------- tests/test_endpoints/test_access_tokens.py | 27 ++++++++++++--- 4 files changed, 69 insertions(+), 30 deletions(-) diff --git a/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py b/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py index 53485b0f..1dc04adb 100644 --- a/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py +++ b/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py @@ -33,7 +33,7 @@ def upgrade(): sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("user_id", GUID(), nullable=False), sa.Column("scope", sa.String(length=64), nullable=False), - sa.Column("token", sa.String(length=255), nullable=True), + sa.Column("token_hash", sa.String(length=64), nullable=True), sa.Column( "created_at", sa.DateTime(), @@ -52,11 +52,13 @@ def upgrade(): ) op.create_index("ix_access_tokens_user_id", "access_tokens", ["user_id"], unique=False) op.create_index("ix_access_tokens_scope", "access_tokens", ["scope"], unique=False) - op.create_index("ix_access_tokens_token", "access_tokens", ["token"], unique=True) + op.create_index( + "ix_access_tokens_token_hash", "access_tokens", ["token_hash"], unique=True + ) def downgrade(): - op.drop_index("ix_access_tokens_token", table_name="access_tokens") + op.drop_index("ix_access_tokens_token_hash", table_name="access_tokens") op.drop_index("ix_access_tokens_scope", table_name="access_tokens") op.drop_index("ix_access_tokens_user_id", table_name="access_tokens") op.drop_table("access_tokens") diff --git a/gramps_webapi/api/resources/access_tokens.py b/gramps_webapi/api/resources/access_tokens.py index 9d8f5083..e89115b1 100644 --- a/gramps_webapi/api/resources/access_tokens.py +++ b/gramps_webapi/api/resources/access_tokens.py @@ -17,7 +17,7 @@ from ...auth import ( get_name, - get_user_access_token, + has_user_access_token, normalize_access_token_scope, revoke_user_access_token, rotate_user_access_token, @@ -29,16 +29,25 @@ from . import ProtectedResource -class AccessTokenStateSchema(Schema): - """Response schema for persistent access token state.""" +class AccessTokenStatusSchema(Schema): + """Response schema for persistent access token status.""" + + active = fields.Boolean( + required=True, + metadata={"description": "Whether a token is currently active."}, + ) + + +class AccessTokenCreateSchema(Schema): + """Response schema for newly created or rotated persistent token.""" active = fields.Boolean( required=True, metadata={"description": "Whether a token is currently active."}, ) token = fields.Str( - allow_none=True, - metadata={"description": "Persistent token value when active, else null."}, + required=True, + metadata={"description": "Newly created persistent token value."}, ) @@ -60,16 +69,16 @@ def _validate_scope(self, scope: str) -> str: abort_with_message(422, str(exc)) raise # unreachable - @api_blueprint.response(200, AccessTokenStateSchema()) + @api_blueprint.response(200, AccessTokenStatusSchema()) def get(self, scope: str): """Get persistent token status for current user and scope.""" require_permissions([PERM_EDIT_OWN_USER]) scope = self._validate_scope(scope) user_name = self._get_user_name() - token = get_user_access_token(user_name, scope) - return {"active": token is not None, "token": token}, 200 + active = has_user_access_token(user_name, scope) + return {"active": active}, 200 - @api_blueprint.response(200, AccessTokenStateSchema()) + @api_blueprint.response(200, AccessTokenCreateSchema()) def post(self, scope: str): """Create or rotate persistent token for current user and scope.""" require_permissions([PERM_EDIT_OWN_USER]) @@ -78,11 +87,11 @@ def post(self, scope: str): token = rotate_user_access_token(user_name, scope) return {"active": True, "token": token}, 200 - @api_blueprint.response(200, AccessTokenStateSchema()) + @api_blueprint.response(200, AccessTokenStatusSchema()) def delete(self, scope: str): """Revoke persistent token for current user and scope.""" require_permissions([PERM_EDIT_OWN_USER]) scope = self._validate_scope(scope) user_name = self._get_user_name() revoke_user_access_token(user_name, scope) - return {"active": False, "token": None}, 200 + return {"active": False}, 200 diff --git a/gramps_webapi/auth/__init__.py b/gramps_webapi/auth/__init__.py index 545ca999..28d83c33 100644 --- a/gramps_webapi/auth/__init__.py +++ b/gramps_webapi/auth/__init__.py @@ -22,6 +22,7 @@ import secrets import uuid +from hashlib import sha256 from datetime import datetime from typing import Any, Dict, List, Optional, Sequence, Set, Union @@ -268,18 +269,26 @@ def normalize_access_token_scope(scope: str) -> str: return normalized_scope -def get_user_access_token(username: str, scope: str) -> Optional[str]: - """Return persistent access token value for user+scope.""" +def _hash_access_token(token: str) -> str: + """Return deterministic SHA-256 hash for a persistent access token.""" + return sha256(token.encode("utf-8")).hexdigest() + + +def has_user_access_token(username: str, scope: str) -> bool: + """Return whether an active persistent access token exists for user+scope.""" scope = normalize_access_token_scope(scope) query = user_db.session.query(User) # pylint: disable=no-member user = query.filter_by(name=username).scalar() if user is None: raise ValueError("User does not exist") query = user_db.session.query(AccessToken) # pylint: disable=no-member - access_token = query.filter_by(user_id=user.id, scope=scope).scalar() - if access_token is None: - return None - return access_token.token + access_token = query.filter( # pylint: disable=no-member + AccessToken.user_id == user.id, + AccessToken.scope == scope, + AccessToken.token_hash.isnot(None), + AccessToken.revoked_at.is_(None), + ).scalar() + return access_token is not None def rotate_user_access_token(username: str, scope: str) -> str: @@ -293,10 +302,11 @@ def rotate_user_access_token(username: str, scope: str) -> str: access_token = query.filter_by(user_id=user.id, scope=scope).scalar() for _ in range(5): token = secrets.token_urlsafe(32) + token_hash = _hash_access_token(token) if access_token is None: access_token = AccessToken(user_id=user.id, scope=scope) user_db.session.add(access_token) # pylint: disable=no-member - access_token.token = token + access_token.token_hash = token_hash access_token.revoked_at = None access_token.updated_at = datetime.utcnow() try: @@ -320,7 +330,7 @@ def revoke_user_access_token(username: str, scope: str) -> None: access_token = query.filter_by(user_id=user.id, scope=scope).scalar() if access_token is None: return - access_token.token = None + access_token.token_hash = None access_token.revoked_at = datetime.utcnow() access_token.updated_at = datetime.utcnow() user_db.session.commit() # pylint: disable=no-member @@ -331,13 +341,14 @@ def get_user_from_access_token(token: str, scope: str) -> Optional["User"]: if not token: return None scope = normalize_access_token_scope(scope) + token_hash = _hash_access_token(token) query = user_db.session.query(User) # pylint: disable=no-member return ( query.join(AccessToken, AccessToken.user_id == User.id) .filter( AccessToken.scope == scope, - AccessToken.token == token, - AccessToken.token.isnot(None), + AccessToken.token_hash == token_hash, + AccessToken.token_hash.isnot(None), AccessToken.revoked_at.is_(None), ) .scalar() @@ -645,7 +656,7 @@ class AccessToken(user_db.Model): # type: ignore GUID, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) scope = mapped_column(sa.String(64), nullable=False, index=True) - token = mapped_column(sa.String(255), nullable=True, unique=True, index=True) + token_hash = mapped_column(sa.String(64), nullable=True, unique=True, index=True) created_at = mapped_column( sa.DateTime, nullable=False, server_default=sa.func.now() ) diff --git a/tests/test_endpoints/test_access_tokens.py b/tests/test_endpoints/test_access_tokens.py index 77607f49..e7b8bd38 100644 --- a/tests/test_endpoints/test_access_tokens.py +++ b/tests/test_endpoints/test_access_tokens.py @@ -13,6 +13,7 @@ import unittest +from gramps_webapi.auth import AccessToken, get_guid, user_db from gramps_webapi.auth.const import ROLE_GUEST, ROLE_OWNER from . import BASE_URL, get_test_client @@ -50,7 +51,8 @@ def test_access_token_lifecycle_owner(self): rv = self.client.get(TOKEN_URL, headers=header) self.assertEqual(rv.status_code, 200) - self.assertEqual(rv.json, {"active": False, "token": None}) + self.assertEqual(rv.json, {"active": False}) + self.assertNotIn("token", rv.json) rv = self.client.post(TOKEN_URL, headers=header) self.assertEqual(rv.status_code, 200) @@ -59,9 +61,21 @@ def test_access_token_lifecycle_owner(self): self.assertIsInstance(token_1, str) self.assertNotEqual(token_1, "") + with self.client.application.app_context(): + user_id = get_guid("owner") + row = ( + user_db.session.query(AccessToken) # pylint: disable=no-member + .filter_by(user_id=user_id, scope=SCOPE) + .one() + ) + self.assertIsNotNone(row.token_hash) + self.assertNotEqual(row.token_hash, token_1) + self.assertEqual(len(row.token_hash), 64) + rv = self.client.get(TOKEN_URL, headers=header) self.assertEqual(rv.status_code, 200) - self.assertEqual(rv.json, {"active": True, "token": token_1}) + self.assertEqual(rv.json, {"active": True}) + self.assertNotIn("token", rv.json) rv = self.client.post(TOKEN_URL, headers=header) self.assertEqual(rv.status_code, 200) @@ -70,15 +84,18 @@ def test_access_token_lifecycle_owner(self): rv = self.client.get(TOKEN_URL, headers=header) self.assertEqual(rv.status_code, 200) - self.assertEqual(rv.json, {"active": True, "token": token_2}) + self.assertEqual(rv.json, {"active": True}) + self.assertNotIn("token", rv.json) rv = self.client.delete(TOKEN_URL, headers=header) self.assertEqual(rv.status_code, 200) - self.assertEqual(rv.json, {"active": False, "token": None}) + self.assertEqual(rv.json, {"active": False}) + self.assertNotIn("token", rv.json) rv = self.client.get(TOKEN_URL, headers=header) self.assertEqual(rv.status_code, 200) - self.assertEqual(rv.json, {"active": False, "token": None}) + self.assertEqual(rv.json, {"active": False}) + self.assertNotIn("token", rv.json) def test_guest_can_manage_own_access_token(self): """Guests can manage own token because they can edit own user settings.""" From ea521e0f416ad09b2ee24e56685476e45f45aecc Mon Sep 17 00:00:00 2001 From: Eli Gold Date: Sat, 30 May 2026 16:49:37 +0200 Subject: [PATCH 7/8] fix(alembic): rebase access-token migration onto latest head --- .../6d8f3cb50b71_add_persistent_access_tokens_table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py b/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py index 1dc04adb..78826b83 100644 --- a/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py +++ b/alembic_users/versions/6d8f3cb50b71_add_persistent_access_tokens_table.py @@ -1,7 +1,7 @@ """add persistent access tokens table Revision ID: 6d8f3cb50b71 -Revises: 2082445b0769 +Revises: c1d2e3f4a5b6 Create Date: 2026-03-31 00:25:00.000000 """ @@ -15,7 +15,7 @@ # revision identifiers, used by Alembic. revision = "6d8f3cb50b71" -down_revision = "2082445b0769" +down_revision = "c1d2e3f4a5b6" branch_labels = None depends_on = None From 4b686e630280335ef82157c050f44222a373df13 Mon Sep 17 00:00:00 2001 From: Eli Gold Date: Thu, 4 Jun 2026 21:14:29 +0200 Subject: [PATCH 8/8] single named unique index for token_hash instead of redundant column-level unique/index flags Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- gramps_webapi/auth/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gramps_webapi/auth/__init__.py b/gramps_webapi/auth/__init__.py index 28d83c33..00cc1fea 100644 --- a/gramps_webapi/auth/__init__.py +++ b/gramps_webapi/auth/__init__.py @@ -656,7 +656,7 @@ class AccessToken(user_db.Model): # type: ignore GUID, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) scope = mapped_column(sa.String(64), nullable=False, index=True) - token_hash = mapped_column(sa.String(64), nullable=True, unique=True, index=True) + token_hash = mapped_column(sa.String(64), nullable=True) created_at = mapped_column( sa.DateTime, nullable=False, server_default=sa.func.now() ) @@ -667,6 +667,7 @@ class AccessToken(user_db.Model): # type: ignore __table_args__ = ( sa.UniqueConstraint("user_id", "scope", name="uq_access_tokens_user_scope"), + sa.Index("ix_access_tokens_token_hash", "token_hash", unique=True), ) def __repr__(self):