Skip to content

FlowAuth: drop the silent absolute-expiry cap (#7274)#7277

Merged
jakejellinek merged 1 commit intomasterfrom
flowauth/drop-absolute-expiry-cap
Apr 29, 2026
Merged

FlowAuth: drop the silent absolute-expiry cap (#7274)#7277
jakejellinek merged 1 commit intomasterfrom
flowauth/drop-absolute-expiry-cap

Conversation

@jakejellinek
Copy link
Copy Markdown
Contributor

@jakejellinek jakejellinek commented Apr 28, 2026

Closes #7274.

Summary

Server and Role carry an absolute latest_token_expiry datetime that ratchets the token-expiry cap downward over time. Renewing a long-lived token therefore requires an admin to log in first and bump those datetimes on the server and every role used by the token — and if they forget, the new token is silently capped at the old (near-expired) date with no UI warning. Wiki-documented runbook, the # feature todo: flag this to the user at token_management.py:138, and #6454 are all symptoms of the same bug.

This PR makes the column nullable as the first half of a two-release deprecation. A NULL value means "no absolute expiry cap, only longest_token_life_minutes bounds the lifetime". Operators opt in per server/role by nulling the column. The actual column drop is a follow-up for a later release once both production deployments have been on the nullable schema for a while.

The mint endpoint also gains an optional lifetime_minutes so users can request a shorter token (addressing the lifetime-half of #5719). Without it, tokens are still issued at the maximum permitted lifetime.

Changes

Backend

  • models.py
    • Server.latest_token_expiry and Role.latest_token_expiry are now nullable=True.
    • Server.next_expiry() and Role.next_expiry() return now + longest_token_life_minutes when latest_token_expiry is NULL.
    • User.token_limits() rewritten to handle NULL role/server caps. If any of the user's roles on the server has NULL, that side imposes no cap; otherwise the most-permissive role wins. Server's value (or NULL) caps that. Returns latest_end: None when there is no cap from either side.
    • User.latest_token_expiry() falls back to now + longest_life when latest_end is None.
    • Role.to_dict() serialises NULL as JSON null.
    • Bonus fix: token_limits previously did not filter by user id, so it returned the most-permissive role on the server across all users. Now correctly filters by User.id == self.id.
  • token_management.py / add_token accepts optional lifetime_minutes (positive integer). Validated against min(server.next_expiry(), role.next_expiry()). Returns 400 with bad_field: lifetime_minutes if invalid or above the cap.
  • servers.pyadd_server, edit_server, get_server, get_roles all tolerate / serialise NULL.
  • roles.pyadd_role, edit_role tolerate NULL. The "role cap can't exceed server cap" check is skipped when either side is NULL.
  • Alembic migrationc1d4e7b9a2f3_nullable_latest_token_expiry.py runs ALTER COLUMN ... DROP NOT NULL on both tables. Down-revision is the current head (976c731ff30f).

Frontend

  • TokenBuilder.jsx gains an optional "Lifetime (minutes)" field. Empty = "issue at the maximum permitted by the selected roles" (existing behaviour).
  • util/api.jscreateToken accepts optional lifetime_minutes and includes it in the request body when set.

Tests

  • test_token_honours_requested_lifetime — short lifetime is honoured.
  • test_token_rejects_lifetime_above_cap — over-cap lifetime → 400.
  • test_token_mint_with_no_absolute_caps — server and roles with NULL latest_token_expiry mint at now + longest_token_life_minutes.

Changelog

Entries under [Unreleased] Added, Changed, and Fixed referencing #7274.

Backwards compatibility

  • Existing rows keep their non-null latest_token_expiry values, so on first deploy of this version nothing changes operationally.
  • JWT contract is unchanged → FlowAPI is unaffected.
  • The Ghana flowauth deployment (separate ghana-flowauth repo, pinned to its own image tag) keeps working until its operator chooses to bump tags.
  • Existing admin endpoints accept the same JSON they did before; they additionally now accept null (or omitted) for latest_token_expiry.

Test plan

  • Backend: pytest flowauth/backend/tests/test_token_generation.py — three new tests pass and existing ones don't regress.
  • Backend: pytest flowauth/backend/tests/test_access_reflects_server_limits.py — still green.
  • Migration: flask db upgrade then flask db downgrade round-trips on a populated database.
  • Frontend: log in, mint a token without a lifetime → token gets max permitted; mint with lifetime_minutes=60 → token expires in 60 minutes; mint with a lifetime above the cap → error message surfaces.
  • Admin UI: existing flow of editing server/role expiry still works.

Related work

This is the second of three planned PRs against FlowAuth. The visibility-of-roles change is in #7276 (issue #7273). The renewal endpoint is #7275 and depends on #7276's tokens_with_roles table.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added optional lifetime_minutes parameter to token minting, allowing tokens to be issued with shorter lifetimes below the configured maximum.
  • Changed

    • Server and role absolute expiry caps are now optional; when disabled, token lifetime is constrained only by the longest configured token life.
  • Bug Fixes

    • Fixed token limit calculations to correctly scope to the current user only.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 28, 2026

Walkthrough

This pull request implements Phase 1 of a two-release deprecation plan converting absolute token expiry caps to relative ones. It introduces optional lifetime_minutes parameter to token minting, makes latest_token_expiry nullable on Server and Role entities, updates token expiry computation to use longest_token_life_minutes when absolute caps are absent, fixes a user-id filtering bug in User.token_limits, and adds corresponding frontend controls.

Changes

Cohort / File(s) Summary
Database schema
flowauth/backend/flowauth/migrations/versions/c1d4e7b9a2f3_nullable_latest_token_expiry.py
New Alembic migration altering server and role tables to make latest_token_expiry nullable during upgrade; downgrade restores NOT NULL constraint.
Model updates
flowauth/backend/flowauth/models.py
Made Server.latest_token_expiry and Role.latest_token_expiry nullable; updated next_expiry() methods to return now + longest_token_life_minutes when absolute cap is NULL; refactored User.token_limits() to scope role expiry queries by user and allow latest_end to be None when neither server nor roles impose absolute caps.
Server/role handlers
flowauth/backend/flowauth/servers.py, flowauth/backend/flowauth/roles.py
Updated JSON parsing to treat latest_token_expiry as optional, converting missing/null/empty inputs to None; modified expiry validation to compare only when both server and role values are non-None; server endpoint now preserves existing value on update when field is omitted.
Token minting
flowauth/backend/flowauth/token_management.py
Added optional lifetime_minutes request parameter; computes max_token_expiry from server and roles; validates lifetime_minutes as positive integer when supplied; rejects requests with expiry exceeding the computed cap; uses adjusted expiry for token generation when provided.
Token generation tests
flowauth/backend/tests/test_token_generation.py
Three new test cases verify: custom lifetime_minutes shorter than cap is honoured, oversized lifetime_minutes triggers 400 error, and tokens mint successfully when absolute expiry caps are NULL.
Frontend token builder
flowauth/frontend/src/TokenBuilder.jsx, flowauth/frontend/src/util/api.js
Added numeric "Lifetime (minutes)" input field to TokenBuilder UI; updated createToken() API function to accept and transmit optional lifetime_minutes parameter in request payload.
Documentation
CHANGELOG.md
Added entries documenting new lifetime_minutes support, nullable latest_token_expiry columns, and fix to User.token_limits user-id filtering.

Sequence Diagram

sequenceDiagram
    actor User
    participant UI as TokenBuilder UI
    participant API as Token API
    participant DB as Database Models
    
    User->>UI: Enter name, select roles, enter lifetime (optional)
    UI->>API: POST /tokens with name, roles, lifetime_minutes
    API->>DB: Fetch Server.next_expiry() and Role.next_expiry() for each role
    DB-->>API: Return expiry values from latest_token_expiry (or compute from longest_token_life_minutes if NULL)
    
    alt lifetime_minutes provided
        API->>API: Calculate requested_expiry = now + lifetime_minutes
        API->>API: Validate requested_expiry ≤ max_token_expiry
        alt Invalid
            API-->>UI: 400 error, lifetime_minutes exceeds cap
        else Valid
            API->>API: Use requested_expiry for token
        end
    else lifetime_minutes omitted
        API->>API: Use max_token_expiry for token
    end
    
    API->>DB: Create token with computed expiry
    DB-->>API: Token created
    API-->>UI: Return token to user
    UI-->>User: Display token
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 Hops of joy, the caps are free!
Nullable fields dance wild and spry,
Lifetime minutes, user's decree—
No more admin dance before tokens fly!
Relative bounds, at last, apply. 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarises the main change: making Server.latest_token_expiry and Role.latest_token_expiry nullable to remove the 'silent absolute-expiry cap' that required admin intervention.
Linked Issues check ✅ Passed The pull request fully implements Release N of the two-release deprecation plan from issue #7274: nullable latest_token_expiry columns, updated next_expiry() logic, optional lifetime_minutes parameter in mint endpoint, and frontend lifetime picker.
Out of Scope Changes check ✅ Passed All changes are in scope and directly address the requirements from issue #7274. The migration, model updates, endpoint modifications, frontend additions, and tests all align with implementing the nullable absolute expiry cap feature.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch flowauth/drop-absolute-expiry-cap

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@cypress
Copy link
Copy Markdown

cypress Bot commented Apr 28, 2026

FlowAuth    Run #25985

Run Properties:  status check passed Passed #25985  •  git commit 5b6989c42c: FlowAuth: drop the silent absolute-expiry cap (#7274)
Project FlowAuth
Branch Review flowauth/drop-absolute-expiry-cap
Run status status check passed Passed #25985
Run duration 00m 42s
Commit git commit 5b6989c42c: FlowAuth: drop the silent absolute-expiry cap (#7274)
Committer Joachim Jellinek
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 0
Tests that did not run due to a developer annotating a test with .skip  Pending 0
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 4
View all changes introduced in this branch ↗︎

Server.latest_token_expiry and Role.latest_token_expiry are now
nullable. A NULL value means the row imposes no absolute expiry cap on
tokens; only longest_token_life_minutes then bounds the lifetime.
Existing rows keep their non-null values, so behaviour is unchanged
until an operator nulls the column out — Ghana and any other
deployment can adopt at their own pace by tag bump.

The mint endpoint now accepts an optional lifetime_minutes so users
can request a token shorter than the maximum permitted by the
selected server and roles. Without it, tokens are issued at the
maximum permitted lifetime as before.

Also fixes a bug in User.token_limits where the role query did not
filter by user, so the function returned the most-permissive role on
the server across all users instead of just the calling user's roles.

Migration is two-step by intent: this change makes the columns
nullable. Dropping them entirely is a follow-up for a later release
once both production deployments have been on the nullable schema for
a while.
@jakejellinek jakejellinek force-pushed the flowauth/drop-absolute-expiry-cap branch from cd6fda6 to 5b6989c Compare April 29, 2026 13:26
@jakejellinek jakejellinek added the FlowAuth Issues related to FlowAuth label Apr 29, 2026
@jakejellinek jakejellinek self-assigned this Apr 29, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
flowauth/backend/flowauth/token_management.py (1)

138-167: ⚠️ Potential issue | 🟠 Major

Capture now once and reject booleans explicitly for lifetime_minutes.

isinstance(True, int) is True, so JSON true currently mints a 1-minute token. Multiple datetime.now() calls can also make an exact-cap request fail on the boundary. Capture the timestamp once and use type() for strict type checking.

Note: The same pattern exists in renew_token() and should be fixed identically.

Suggested fix
-    max_token_expiry = min(server.next_expiry(), min(rr.next_expiry() for rr in roles))
+    now = datetime.datetime.now()
+    max_token_expiry = min(server.next_expiry(), min(rr.next_expiry() for rr in roles))
 
-    if max_token_expiry < datetime.datetime.now():
+    if max_token_expiry <= now:
         raise Unauthorized(f"Token for {current_user.username} expired")
 
-        if not isinstance(requested_lifetime, int) or requested_lifetime <= 0:
+        if type(requested_lifetime) is not int or requested_lifetime <= 0:
             raise InvalidUsage(
                 "lifetime_minutes must be a positive integer",
                 payload={"bad_field": "lifetime_minutes"},
             )
-        requested_expiry = datetime.datetime.now() + datetime.timedelta(
+        requested_expiry = now + datetime.timedelta(
             minutes=requested_lifetime
         )
 
-        lifetime=token_expiry - datetime.datetime.now(),
+        lifetime=token_expiry - now,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flowauth/backend/flowauth/token_management.py` around lines 138 - 167,
Capture a single "now" datetime at the start of the token issuance flow and use
that single timestamp for all expiry comparisons and for computing lifetimes
(replace multiple datetime.datetime.now() calls used around max_token_expiry
checks and when creating requested_expiry and lifetime passed to
generate_token); also reject boolean JSON values by using
type(requested_lifetime) is not int (instead of isinstance) when validating
lifetime_minutes. Apply the identical change in renew_token() so both issuance
and renewal use the same captured now and strict int type-checking for
lifetime_minutes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@flowauth/backend/flowauth/models.py`:
- Around line 156-196: User.latest_token_expiry currently mixes independent
maxima (role_expiries and longest) and can produce an impossible combo; change
the logic to iterate actual Role rows (join User.roles) and for each role
compute that role's effective end as min(server.latest_token_expiry,
role.latest_token_expiry, now +
timedelta(minutes=min(server.longest_token_life_minutes,
role.longest_token_life_minutes))) treating None as “no cap” (i.e., ignored in
the min), then take the maximum across those per-role effective ends and also
set longest_life as min(server.longest_token_life_minutes,
role.longest_token_life_minutes) computed per role and then max across roles;
update the queries around role_expiries and longest so you fetch
Role.latest_token_expiry and Role.longest_token_life_minutes together (via the
join used in the current code) and replace the independent longest/role_expiries
computations with this per-role computation inside User.latest_token_expiry().

In `@flowauth/backend/flowauth/roles.py`:
- Around line 56-61: Wrap the datetime parsing into a small helper (e.g.,
parse_iso_datetime or parse_token_expiry) that takes the raw string, returns a
datetime or None, and catches ValueError to raise InvalidUsage with a bad_field
payload (include the field name like "latest_token_expiry"). Replace the inline
datetime.datetime.strptime calls that set json["latest_token_expiry"] and the
other strptime at lines ~119–123 with calls to this helper so malformed
timestamps produce a 400 InvalidUsage containing bad_field instead of bubbling a
ValueError (ensure you reference and raise the existing InvalidUsage type used
in this module).

In `@flowauth/frontend/src/TokenBuilder.jsx`:
- Around line 49-50: Token lifetime UI never computes or enforces the effective
maximum from server.longest_token_life_minutes and the selected roles, so update
TokenBuilder.jsx to compute the allowed max (e.g. derive maxLifetime =
Math.min(server.longest_token_life_minutes,
selectedRoles.map(r=>r.max_life_minutes).filter(Boolean)...) or similar) and use
that value to (1) clamp lifetimeMinutes on input/change via setLifetimeMinutes,
(2) add a max attribute/validation to the input control and (3) update the
helper text (and any validation error) to show the actual computed cap.
Reference the lifetimeMinutes/setLifetimeMinutes state and the
server.longest_token_life_minutes and selected roles data when making these
changes so the picker reflects role/server caps immediately.

In `@flowauth/frontend/src/util/api.js`:
- Around line 398-405: The createToken function currently uses parseInt on
lifetime_minutes which silently accepts decimals and exponential notation or
yields NaN; replace that with strict integer validation (e.g. test
lifetime_minutes against a digits-only pattern like /^\d+$/) before setting
body.lifetime_minutes and only then parseInt to an integer; if the validation
fails, reject/throw or return a rejected Promise indicating invalid
lifetime_minutes so the payload never sends an ambiguous/null lifetime.

---

Outside diff comments:
In `@flowauth/backend/flowauth/token_management.py`:
- Around line 138-167: Capture a single "now" datetime at the start of the token
issuance flow and use that single timestamp for all expiry comparisons and for
computing lifetimes (replace multiple datetime.datetime.now() calls used around
max_token_expiry checks and when creating requested_expiry and lifetime passed
to generate_token); also reject boolean JSON values by using
type(requested_lifetime) is not int (instead of isinstance) when validating
lifetime_minutes. Apply the identical change in renew_token() so both issuance
and renewal use the same captured now and strict int type-checking for
lifetime_minutes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 51e392a2-ded0-46aa-96e8-e3fc4da5d249

📥 Commits

Reviewing files that changed from the base of the PR and between 3ca5548 and 5b6989c.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • flowauth/backend/flowauth/migrations/versions/c1d4e7b9a2f3_nullable_latest_token_expiry.py
  • flowauth/backend/flowauth/models.py
  • flowauth/backend/flowauth/roles.py
  • flowauth/backend/flowauth/servers.py
  • flowauth/backend/flowauth/token_management.py
  • flowauth/backend/tests/test_token_generation.py
  • flowauth/frontend/src/TokenBuilder.jsx
  • flowauth/frontend/src/util/api.js

Comment on lines +156 to 196
role_expiries = (
db.session.execute(
db.select(Role.latest_token_expiry)
.where(Role.server_id == server.id)
.join(User.roles)
.where(User.id == self.id)
)
.scalars()
.all()
)

longest = db.session.execute(
db.select(Role.longest_token_life_minutes)
.where(Role.server_id == server.id)
.join(User.roles)
.where(User.id == self.id)
.order_by(Role.longest_token_life_minutes.desc())
).scalar()

if not latest or not longest:
raise Unauthorized(f"No roles for {self.username} on {Server.name}")
if not role_expiries or longest is None:
raise Unauthorized(f"No roles for {self.username} on {server.name}")

if any(expiry is None for expiry in role_expiries):
roles_latest = None
else:
roles_latest = max(role_expiries)

server_latest = server.latest_token_expiry
if roles_latest is None and server_latest is None:
latest_end = None
elif roles_latest is None:
latest_end = server_latest
elif server_latest is None:
latest_end = roles_latest
else:
latest_end = min(server_latest, roles_latest)

return {
"latest_end": min(server.latest_token_expiry, latest),
"latest_end": latest_end,
"longest_life": min(server.longest_token_life_minutes, longest),
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't combine cap and lifetime maxima from different roles.

roles_latest and longest are computed independently, so they can come from different roles. That makes User.latest_token_expiry() overstate the real limit. For example, a user with roles (latest=None, life=10) and (latest=now+20m, life=1000) gets now+1000m here, but the true maximum is now+20m. Please compute each role's effective end first — min(server cap, role cap, now + min(server life, role life)) — and then take the maximum across those per-role values instead of mixing independent maxima.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flowauth/backend/flowauth/models.py` around lines 156 - 196,
User.latest_token_expiry currently mixes independent maxima (role_expiries and
longest) and can produce an impossible combo; change the logic to iterate actual
Role rows (join User.roles) and for each role compute that role's effective end
as min(server.latest_token_expiry, role.latest_token_expiry, now +
timedelta(minutes=min(server.longest_token_life_minutes,
role.longest_token_life_minutes))) treating None as “no cap” (i.e., ignored in
the min), then take the maximum across those per-role effective ends and also
set longest_life as min(server.longest_token_life_minutes,
role.longest_token_life_minutes) computed per role and then max across roles;
update the queries around role_expiries and longest so you fetch
Role.latest_token_expiry and Role.longest_token_life_minutes together (via the
join used in the current code) and replace the independent longest/role_expiries
computations with this per-role computation inside User.latest_token_expiry().

Comment on lines +56 to 61
raw_latest = json.get("latest_token_expiry")
json["latest_token_expiry"] = (
datetime.datetime.strptime(raw_latest, "%Y-%m-%dT%H:%M:%S.%fZ")
if raw_latest
else None
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
import datetime
samples = ["2026-04-28T00:00:00.000000Z", "not-a-date", "2026-04-28", ""]
for raw in samples:
    try:
        parsed = datetime.datetime.strptime(raw, "%Y-%m-%dT%H:%M:%S.%fZ") if raw else None
        print(f"{raw!r} -> {parsed!r}")
    except Exception as exc:
        print(f"{raw!r} -> {type(exc).__name__}: {exc}")
PY

Repository: Flowminder/FlowKit

Length of output: 337


🏁 Script executed:

cat -n flowauth/backend/flowauth/roles.py | head -130 | tail -80

Repository: Flowminder/FlowKit

Length of output: 3702


🏁 Script executed:

rg "InvalidUsage" flowauth/backend/flowauth/ -A 2 -B 2 | head -50

Repository: Flowminder/FlowKit

Length of output: 3294


🏁 Script executed:

rg "strptime|datetime.datetime" flowauth/backend/flowauth/roles.py -B 2 -A 2

Repository: Flowminder/FlowKit

Length of output: 453


Malformed expiry timestamps will return 500 instead of 400.

The strptime calls at lines 56–61 and 119–123 raise ValueError on invalid input without catching it, bypassing field-level InvalidUsage errors. Extract these to a helper function that catches ValueError and raises InvalidUsage with a bad_field payload instead.

Suggested refactor
+def _parse_optional_latest_token_expiry(value):
+    if not value:
+        return None
+    try:
+        return datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ")
+    except ValueError as err:
+        raise InvalidUsage(
+            "Invalid latest_token_expiry",
+            payload={"bad_field": "latest_token_expiry"},
+        ) from err
+
 def add_role():
     json = request.get_json()
-    raw_latest = json.get("latest_token_expiry")
-    json["latest_token_expiry"] = (
-        datetime.datetime.strptime(raw_latest, "%Y-%m-%dT%H:%M:%S.%fZ")
-        if raw_latest
-        else None
-    )
+    json["latest_token_expiry"] = _parse_optional_latest_token_expiry(
+        json.get("latest_token_expiry")
+    )
@@
         elif key == "latest_token_expiry":
-            value = (
-                datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ")
-                if value
-                else None
-            )
+            value = _parse_optional_latest_token_expiry(value)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
raw_latest = json.get("latest_token_expiry")
json["latest_token_expiry"] = (
datetime.datetime.strptime(raw_latest, "%Y-%m-%dT%H:%M:%S.%fZ")
if raw_latest
else None
)
json["latest_token_expiry"] = _parse_optional_latest_token_expiry(
json.get("latest_token_expiry")
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flowauth/backend/flowauth/roles.py` around lines 56 - 61, Wrap the datetime
parsing into a small helper (e.g., parse_iso_datetime or parse_token_expiry)
that takes the raw string, returns a datetime or None, and catches ValueError to
raise InvalidUsage with a bad_field payload (include the field name like
"latest_token_expiry"). Replace the inline datetime.datetime.strptime calls that
set json["latest_token_expiry"] and the other strptime at lines ~119–123 with
calls to this helper so malformed timestamps produce a 400 InvalidUsage
containing bad_field instead of bubbling a ValueError (ensure you reference and
raise the existing InvalidUsage type used in this module).

Comment on lines +49 to 50
const [lifetimeMinutes, setLifetimeMinutes] = useState("");
const [tokenErrorOpen, setTokenErrorOpen] = useState(false);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The new lifetime field still does not reflect the actual cap.

This component never computes a max from server.longest_token_life_minutes or the selected roles, so the user can only discover the valid range after a backend 400. That leaves the issue’s “picker reflects role/server caps” requirement incomplete and makes the helper text misleading.

Also applies to: 190-200

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flowauth/frontend/src/TokenBuilder.jsx` around lines 49 - 50, Token lifetime
UI never computes or enforces the effective maximum from
server.longest_token_life_minutes and the selected roles, so update
TokenBuilder.jsx to compute the allowed max (e.g. derive maxLifetime =
Math.min(server.longest_token_life_minutes,
selectedRoles.map(r=>r.max_life_minutes).filter(Boolean)...) or similar) and use
that value to (1) clamp lifetimeMinutes on input/change via setLifetimeMinutes,
(2) add a max attribute/validation to the input control and (3) update the
helper text (and any validation error) to show the actual computed cap.
Reference the lifetimeMinutes/setLifetimeMinutes state and the
server.longest_token_life_minutes and selected roles data when making these
changes so the picker reflects role/server caps immediately.

Comment on lines +398 to +405
export async function createToken(name, server_id, roles, lifetime_minutes) {
const body = { name: name, roles: roles };
if (lifetime_minutes != null && lifetime_minutes !== "") {
body.lifetime_minutes = parseInt(lifetime_minutes, 10);
}
var dat = {
method: "POST",
body: JSON.stringify({ name: name, roles: roles }),
body: JSON.stringify(body),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
node - <<'NODE'
for (const raw of ["1.5", "1e2", " ", "08"]) {
  const parsed = parseInt(raw, 10);
  console.log({
    raw,
    parsed,
    json: JSON.stringify({ lifetime_minutes: parsed }),
  });
}
NODE

Repository: Flowminder/FlowKit

Length of output: 296


🏁 Script executed:

cd flowauth && find . -name "api.js" -path "*/frontend/src/util/*" 2>/dev/null

Repository: Flowminder/FlowKit

Length of output: 89


🏁 Script executed:

cd flowauth && git ls-files | grep -E "frontend/src/util/api\.js"

Repository: Flowminder/FlowKit

Length of output: 87


🏁 Script executed:

cd flowauth && head -420 frontend/src/util/api.js | tail -30

Repository: Flowminder/FlowKit

Length of output: 948


🏁 Script executed:

cd flowauth && find . -path "*/backend*" -name "*.py" -o -path "*/server*" -name "*.py" | head -20

Repository: Flowminder/FlowKit

Length of output: 830


🏁 Script executed:

cd flowauth && rg -i "tokens/tokens" --type py -A 5 -B 2

Repository: Flowminder/FlowKit

Length of output: 11923


🏁 Script executed:

cd flowauth && rg -i "lifetime_minutes" backend/ --type py -B 3 -A 3

Repository: Flowminder/FlowKit

Length of output: 6549


🏁 Script executed:

cd flowauth && rg -i "bad_field.*lifetime_minutes" backend/ --type py -B 5 -A 5

Repository: Flowminder/FlowKit

Length of output: 4007


Frontend validation for lifetime_minutes is insufficient.

The parseInt approach silently truncates decimals ("1.5" → 1, "1e2" → 1) and converts whitespace to NaN, which serialises as null and causes the backend to issue a default-lifetime token. Add strict integer validation before building the payload.

Suggested fix
 export async function createToken(name, server_id, roles, lifetime_minutes) {
-  const body = { name: name, roles: roles };
+  const body = { name, roles };
   if (lifetime_minutes != null && lifetime_minutes !== "") {
-    body.lifetime_minutes = parseInt(lifetime_minutes, 10);
+    const parsedLifetime = Number(lifetime_minutes);
+    if (!Number.isInteger(parsedLifetime) || parsedLifetime <= 0) {
+      throw new APIError("lifetime_minutes must be a positive integer");
+    }
+    body.lifetime_minutes = parsedLifetime;
   }
   var dat = {
     method: "POST",
     body: JSON.stringify(body),
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flowauth/frontend/src/util/api.js` around lines 398 - 405, The createToken
function currently uses parseInt on lifetime_minutes which silently accepts
decimals and exponential notation or yields NaN; replace that with strict
integer validation (e.g. test lifetime_minutes against a digits-only pattern
like /^\d+$/) before setting body.lifetime_minutes and only then parseInt to an
integer; if the validation fails, reject/throw or return a rejected Promise
indicating invalid lifetime_minutes so the payload never sends an ambiguous/null
lifetime.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 29, 2026

Codecov Report

❌ Patch coverage is 77.61194% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.95%. Comparing base (3ca5548) to head (5b6989c).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
flowauth/backend/flowauth/models.py 73.07% 7 Missing ⚠️
...sions/c1d4e7b9a2f3_nullable_latest_token_expiry.py 75.00% 5 Missing ⚠️
flowauth/backend/flowauth/roles.py 60.00% 2 Missing ⚠️
flowauth/backend/flowauth/token_management.py 90.90% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #7277      +/-   ##
==========================================
- Coverage   92.08%   91.95%   -0.13%     
==========================================
  Files         278      256      -22     
  Lines       10826    10652     -174     
  Branches      697      681      -16     
==========================================
- Hits         9969     9795     -174     
- Misses        704      705       +1     
+ Partials      153      152       -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jakejellinek jakejellinek merged commit df509f6 into master Apr 29, 2026
38 of 40 checks passed
@jakejellinek jakejellinek deleted the flowauth/drop-absolute-expiry-cap branch April 29, 2026 13:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

FlowAuth Issues related to FlowAuth

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FlowAuth: latest_token_expiry silently caps tokens, requiring admin-bumps before every renewal

1 participant