feat(pat): regenerate a token's secret and reset its expiry

POST /api/user/tokens/<id>/regenerate swaps the secret of an existing token
in place: name, scopes and restrictions stay, the old secret stops matching
at once, and the expiry is reset. The lifetime defaults to the one the token
was last issued with (clamped to today's policy) or to expires_in_days when
given. An expired token can be renewed this way; a revoked one cannot. It is
session only like the rest of token management, and writes a pat_regenerated
audit event. regenerated_at records the rotation.
This commit is contained in:
arc53-machine committed 2026-09-21 10:32:17 +01:00
1 parent 3e38dc19ec
commit 91f2ec2f09
10 files changed
+275 -4

No files matched your search

@@ -20,7 +20,7 @@ Every token is limited in three ways:
3. Optionally restrict it to specific resources and pick an expiry.
4. Copy the token. It starts with `dgpt_pat_` and is shown **once**. DocsGPT stores only a hash of it, so a lost token cannot be recovered. Revoke it and create a new one.
Tokens can only be created and revoked from a signed-in session. A token cannot create, list or revoke tokens, so a leaked token cannot mint a replacement for itself.
Tokens can only be created, regenerated and revoked from a signed-in session. A token cannot create, list, regenerate or revoke tokens, so a leaked token cannot mint a replacement for itself.
## Using a token
@@ -159,11 +159,12 @@ Restrictions and `agents apply`: a token restricted to specific agents can apply
- A token created without an explicit lifetime expires after `PAT_DEFAULT_LIFETIME_DAYS` (90 by default). Users can choose any lifetime up to `PAT_MAX_LIFETIME_DAYS` (365 by default).
- Non-expiring tokens are available only when the operator sets `PAT_ALLOW_NON_EXPIRING=true`.
- **Regenerate** in **Settings → Access Tokens** issues a new secret for the same token and resets its expiry. The name, scopes and restrictions stay; the old secret stops working immediately, so update whatever uses it. The new lifetime defaults to the one the token was last issued with, and an expired token can be renewed this way (a revoked one cannot). This is the way to rotate a secret or extend a token without rebuilding its scopes.
- Revoking a token in **Settings → Access Tokens** takes effect on the next request.
- Admins can list a user's tokens with `GET /api/admin/users/<user_id>/tokens` and revoke any token with `DELETE /api/admin/tokens/<token_id>`. The admin **revoke sessions** action also revokes all of that user's tokens.
- Tokens of a deactivated user (through the admin API or SCIM) stop working immediately and work again if the user is reactivated.
- `GET /api/user/tokens` reports a token past its expiry as `"status": "expired"`.
- Token creation and revocation are recorded in the authentication audit log (`pat_created`, `pat_revoked`), visible to admins.
- Token creation and revocation are recorded in the authentication audit log (`pat_created`, `pat_regenerated`, `pat_revoked`), visible to admins.
An expired token's name can be reused: creating a token with that name retires the expired one.
@@ -191,6 +192,7 @@ These endpoints need a signed-in session and cannot be called with a token.
| --- | --- |
| `GET /api/user/tokens` | List your tokens, the scope catalog and the server's token policy |
| `POST /api/user/tokens` | Create a token. Body: `name`, `scopes`, optional `resource_filter`, optional `expires_in_days` (`0` = never, when allowed). The response carries the plaintext `token` once |
| `POST /api/user/tokens/<id>/regenerate` | New secret and new expiry for the same token. Optional body `expires_in_days`; omitted = the lifetime it was last issued with. The response carries the plaintext `token` once |
| `DELETE /api/user/tokens/<id>` | Revoke a token |
## Good practice
@@ -10,7 +10,8 @@ user can tell their tokens apart in the UI.
itself). ``resource_filter`` optionally narrows a resource family to specific
ids, e.g. ``{"agents": ["<uuid>"]}``; an absent family is unrestricted within
the token's scopes. ``expires_at`` is NULL only when the operator allows
non-expiring tokens.
non-expiring tokens. Regenerating a token swaps its secret in place and stamps
``regenerated_at``; the row, its name, scopes and restrictions stay.
``user_id`` is the auth ``sub``; no FK or trigger, mirroring ``devices`` and
``user_roles`` so a token row never blocks user deletion.
@@ -47,6 +48,7 @@ def upgrade() -> None:
last_used_at TIMESTAMPTZ,
last_used_ip TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
regenerated_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoke_reason TEXT
);
+62
View File
@@ -23,6 +23,7 @@ from docsgpt.api.pat.tokens import (
is_pat,
normalize_resource_filter,
normalize_scopes,
renewal_lifetime_days,
resolve_expiry,
)
from docsgpt.api.user.authz import admin_required
@@ -84,6 +85,7 @@ def serialize_token(row: dict) -> dict:
"last_used_at": row.get("last_used_at"),
"last_used_ip": row.get("last_used_ip"),
"created_at": row.get("created_at"),
"regenerated_at": row.get("regenerated_at"),
"revoked_at": row.get("revoked_at"),
}
@@ -214,6 +216,66 @@ class PersonalAccessToken(Resource):
return make_response(jsonify({"success": True}), 200)
@pat_ns.route("/user/tokens/<string:token_id>/regenerate")
class PersonalAccessTokenRegenerate(Resource):
def post(self, token_id):
"""Issue a new secret for a token and reset its expiry.
Name, scopes and restrictions stay; the old secret stops working at
once. ``expires_in_days`` is optional and defaults to the lifetime the
token was last issued with. An expired token can be renewed this way; a
revoked one cannot. The plaintext ``token`` is returned here and never again.
"""
user_id = _session_user_id()
if not user_id:
return _error("Authentication required", 401)
if not auth_type_supports_pats():
return _error("Personal access tokens are not available on this server", 403)
if not _valid_uuid(token_id):
return _error("Token not found", 404)
body = request.get_json(silent=True)
if body is None:
body = {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
token, token_hash, token_prefix = generate_token()
with db_session() as conn:
repo = PersonalAccessTokensRepository(conn)
current = repo.get(token_id, user_id)
if not current or current["status"] != "active":
return _error("Token not found", 404)
requested = body.get("expires_in_days")
if requested is None:
requested = renewal_lifetime_days(current)
try:
expires_at = resolve_expiry(requested)
except ValueError as exc:
return _error(str(exc), 400)
row = repo.regenerate(
token_id, user_id, token_hash=token_hash, token_prefix=token_prefix, expires_at=expires_at
)
if not row:
# Revoked between the read and the write.
return _error("Token not found", 404)
AuthEventsRepository(conn).insert(
user_id,
"pat_regenerated",
ip=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
metadata={
"token_id": token_id,
"name": row["name"],
"expires_at": row.get("expires_at"),
"previous_expires_at": current.get("expires_at"),
},
)
return make_response(
jsonify({"success": True, "token": token, "personal_access_token": serialize_token(row)}),
200,
)
@pat_ns.route("/admin/users/<string:user_id>/tokens")
class AdminUserTokens(Resource):
@admin_required
+1
View File
@@ -321,6 +321,7 @@ DENIED: dict[str, tuple[str, ...]] = {
"/": ("*",),
"/api/user/tokens": ("*",),
"/api/user/tokens/<string:token_id>": ("*",),
"/api/user/tokens/<string:token_id>/regenerate": ("*",),
"/api/generate_token": ("*",),
"/api/combine": ("*",),
"/api/download": ("*",),
+27
View File
@@ -169,6 +169,33 @@ def resolve_expiry(expires_in_days: Any) -> Optional[datetime]:
return datetime.now(timezone.utc) + timedelta(days=days)
def _parse_moment(value: Any) -> Optional[datetime]:
if not value:
return None
try:
moment = value if isinstance(value, datetime) else datetime.fromisoformat(str(value))
except ValueError:
return None
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
def renewal_lifetime_days(row: dict) -> Optional[int]:
"""The lifetime a token was last issued with, for renewing it on the same terms.
``0`` for a non-expiring token, ``None`` when it cannot be derived (the
caller then falls back to the default). The result is clamped to today's
maximum, since the policy may have tightened since the token was issued.
"""
issued = _parse_moment(row.get("regenerated_at")) or _parse_moment(row.get("created_at"))
expires = _parse_moment(row.get("expires_at"))
if expires is None:
return 0 if settings.PAT_ALLOW_NON_EXPIRING else None
if issued is None:
return None
days = round((expires - issued).total_seconds() / 86400)
return max(1, min(days, settings.PAT_MAX_LIFETIME_DAYS))
def _client_ip(request) -> Optional[str]:
# Flask exposes remote_addr; Starlette exposes client.host.
ip = getattr(request, "remote_addr", None)
+1
View File
@@ -1098,6 +1098,7 @@ personal_access_tokens_table = Table(
Column("last_used_at", DateTime(timezone=True)),
Column("last_used_ip", Text),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("regenerated_at", DateTime(timezone=True)),
Column("revoked_at", DateTime(timezone=True)),
Column("revoke_reason", Text),
CheckConstraint("status IN ('active', 'revoked')", name="personal_access_tokens_status_check"),
@@ -14,7 +14,7 @@ from docsgpt.storage.db.base_repository import row_to_dict
# token_hash never leaves the repository except through find_active_by_hash.
_PUBLIC_COLUMNS = (
"id, user_id, name, token_prefix, scopes, resource_filter, status, "
"expires_at, last_used_at, last_used_ip, created_at, revoked_at, revoke_reason"
"expires_at, last_used_at, last_used_ip, created_at, regenerated_at, revoked_at, revoke_reason"
)
@@ -155,6 +155,41 @@ class PersonalAccessTokensRepository:
{"id": token_id, "ip": ip, "min_interval": min_interval_seconds},
)
def regenerate(
self,
token_id: str,
user_id: str,
*,
token_hash: str,
token_prefix: str,
expires_at: Optional[datetime],
) -> Optional[dict]:
"""Swap a live token's secret and expiry in place. The old secret stops matching at once.
Expired tokens qualify (renewal is the point); revoked ones do not.
Usage fields are cleared because they described the old secret.
"""
row = self._conn.execute(
text(
f"""
UPDATE personal_access_tokens
SET token_hash = :token_hash, token_prefix = :token_prefix,
expires_at = :expires_at, regenerated_at = now(),
last_used_at = NULL, last_used_ip = NULL
WHERE id = CAST(:id AS uuid) AND user_id = :user_id AND status = 'active'
RETURNING {_PUBLIC_COLUMNS}
"""
),
{
"id": token_id,
"user_id": user_id,
"token_hash": token_hash,
"token_prefix": token_prefix,
"expires_at": expires_at,
},
).fetchone()
return row_to_dict(row) if row is not None else None
def revoke(self, token_id: str, user_id: Optional[str] = None, *, reason: str = "user_revoked") -> bool:
"""Revoke one token. ``user_id=None`` is the admin path (any owner)."""
sql = (
+114
View File
@@ -223,6 +223,120 @@ class TestEndToEnd:
assert response.status_code == 401
class TestRegenerate:
def _regen(self, client, token_id, **body):
with _session():
return client.post(f"/api/user/tokens/{token_id}/regenerate", json=body)
def test_new_secret_works_old_one_stops_and_the_rest_is_kept(self, client, db):
created = json.loads(
_create(client, scopes=["prompts:read"], resource_filter={"prompts": [AGENT_A]}).data
)
old, token_id = created["token"], created["personal_access_token"]["id"]
response = self._regen(client, token_id)
assert response.status_code == 200
body = json.loads(response.data)
new, public = body["token"], body["personal_access_token"]
assert new.startswith("dgpt_pat_") and new != old
assert public["id"] == token_id and public["name"] == "ci"
assert public["scopes"] == ["prompts:read"]
assert public["resource_filter"] == {"prompts": [AGENT_A]}
assert public["token_prefix"] == new[:15]
assert public["regenerated_at"] is not None
assert public["last_used_at"] is None
assert client.get("/api/user/me", headers={"Authorization": f"Bearer {old}"}).status_code == 401
me = client.get("/api/user/me", headers={"Authorization": f"Bearer {new}"})
assert me.status_code == 200
assert json.loads(me.data)["token"]["id"] == token_id
stored = db.execute(text("SELECT token_hash FROM personal_access_tokens")).scalar_one()
assert stored == pat_tokens.hash_token(new)
def test_expiry_is_reset_to_the_original_lifetime(self, client, db):
token_id = json.loads(_create(client, expires_in_days=30).data)["personal_access_token"]["id"]
# 20 days in: 10 days left.
db.execute(
text(
"UPDATE personal_access_tokens SET created_at = now() - interval '20 days', "
"expires_at = now() + interval '10 days'"
)
)
self._regen(client, token_id)
days_left = db.execute(
text("SELECT extract(epoch FROM expires_at - now()) / 86400 FROM personal_access_tokens")
).scalar_one()
assert 29.9 < float(days_left) < 30.1
def test_explicit_lifetime_is_honoured_and_capped(self, client, db):
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
assert self._regen(client, token_id, expires_in_days=7).status_code == 200
days_left = db.execute(
text("SELECT extract(epoch FROM expires_at - now()) / 86400 FROM personal_access_tokens")
).scalar_one()
assert 6.9 < float(days_left) < 7.1
assert self._regen(client, token_id, expires_in_days=366).status_code == 400
assert self._regen(client, token_id, expires_in_days=0).status_code == 400
def test_an_expired_token_can_be_renewed(self, client, db):
created = json.loads(_create(client, expires_in_days=30).data)
db.execute(
text(
"UPDATE personal_access_tokens SET created_at = now() - interval '31 days', "
"expires_at = now() - interval '1 day'"
)
)
old_headers = {"Authorization": f"Bearer {created['token']}"}
assert client.get("/api/user/me", headers=old_headers).status_code == 401
body = json.loads(self._regen(client, created["personal_access_token"]["id"]).data)
assert body["personal_access_token"]["status"] == "active"
headers = {"Authorization": f"Bearer {body['token']}"}
assert client.get("/api/user/me", headers=headers).status_code == 200
def test_non_expiring_token_stays_non_expiring_only_while_the_operator_allows_it(
self, client, db, monkeypatch
):
monkeypatch.setattr(pat_tokens.settings, "PAT_ALLOW_NON_EXPIRING", True)
token_id = json.loads(_create(client, expires_in_days=0).data)["personal_access_token"]["id"]
assert json.loads(self._regen(client, token_id).data)["personal_access_token"]["expires_at"] is None
monkeypatch.setattr(pat_tokens.settings, "PAT_ALLOW_NON_EXPIRING", False)
renewed = json.loads(self._regen(client, token_id).data)["personal_access_token"]
assert renewed["expires_at"] is not None # falls back to the default lifetime
def test_revoked_foreign_unknown_and_malformed_tokens_are_not_found(self, client, db):
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
with _session(sub="bob"):
assert client.post(f"/api/user/tokens/{token_id}/regenerate").status_code == 404
with _session():
client.delete(f"/api/user/tokens/{token_id}")
assert self._regen(client, token_id).status_code == 404
assert self._regen(client, AGENT_A).status_code == 404
assert self._regen(client, f"urn:uuid:{AGENT_A}").status_code == 404
def test_a_token_cannot_regenerate_itself_or_others(self, client, db):
created = json.loads(_create(client, scopes=list(pat_tokens.SCOPES)).data)
headers = {"Authorization": f"Bearer {created['token']}"}
token_id = created["personal_access_token"]["id"]
response = client.post(f"/api/user/tokens/{token_id}/regenerate", headers=headers)
assert response.status_code == 403
assert json.loads(response.data)["error"] == "not_available_to_tokens"
def test_requires_a_session_and_an_object_body(self, client, db):
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
with patch("docsgpt.app.handle_auth", return_value=None):
assert client.post(f"/api/user/tokens/{token_id}/regenerate").status_code == 401
with _session():
assert client.post(f"/api/user/tokens/{token_id}/regenerate", json=[1]).status_code == 400
def test_audited_without_the_secret(self, client, db):
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
self._regen(client, token_id)
metadata = db.execute(
text("SELECT metadata FROM auth_events WHERE event = 'pat_regenerated'")
).scalar_one()
assert metadata["token_id"] == token_id
assert "dgpt_pat_" not in json.dumps(metadata)
class TestRevoke:
def test_cannot_revoke_someone_elses_token(self, client, db):
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
+1
View File
@@ -91,6 +91,7 @@ class TestClassification:
("/api/user/tokens", "POST"),
("/api/user/tokens", "GET"),
("/api/user/tokens/<string:token_id>", "DELETE"),
("/api/user/tokens/<string:token_id>/regenerate", "POST"),
("/api/admin/users", "GET"),
("/api/admin/tokens/<string:token_id>", "DELETE"),
("/api/generate_token", "GET"),
@@ -179,3 +179,29 @@ class TestCountAndUsage:
assert repo.get(str(row["id"]))["last_used_ip"] == "10.0.0.1"
repo.touch_last_used(str(row["id"]), "10.0.0.3", min_interval_seconds=0)
assert repo.get(str(row["id"]))["last_used_ip"] == "10.0.0.3"
class TestRegenerate:
def test_swaps_the_secret_and_resets_expiry_and_usage(self, pg_conn):
repo = PersonalAccessTokensRepository(pg_conn)
row = _create(repo, expires_at=datetime.now(timezone.utc) - timedelta(days=1))
repo.touch_last_used(str(row["id"]), "10.0.0.1")
future = datetime.now(timezone.utc) + timedelta(days=30)
renewed = repo.regenerate(
str(row["id"]), "u1", token_hash="h-new", token_prefix="dgpt_pat_new123", expires_at=future
)
assert renewed["id"] == row["id"] and renewed["name"] == "ci"
assert renewed["token_prefix"] == "dgpt_pat_new123"
assert renewed["regenerated_at"] is not None
assert renewed["last_used_at"] is None and renewed["last_used_ip"] is None
assert repo.find_active_by_hash("h1") is None
assert repo.find_active_by_hash("h-new")["id"] == row["id"]
def test_owner_scoped_and_never_revives_a_revoked_token(self, pg_conn):
repo = PersonalAccessTokensRepository(pg_conn)
row = _create(repo)
kwargs = dict(token_hash="h-new", token_prefix="dgpt_pat_new123", expires_at=None)
assert repo.regenerate(str(row["id"]), "someone-else", **kwargs) is None
repo.revoke(str(row["id"]), "u1")
assert repo.regenerate(str(row["id"]), "u1", **kwargs) is None
assert repo.find_active_by_hash("h-new") is None