Skip to content

fix(backend:auth): enforce link expiration on token refresh - #315

Merged
johaven merged 4 commits into
Sync-in:mainfrom
tynianov1:fix/link-expiry-on-refresh
Oct 29, 2026
Merged

johaven merged 4 commits into
Sync-in:mainfrom
tynianov1:fix/link-expiry-on-refresh

Conversation

@tynianov1

Copy link
Copy Markdown
Contributor

What was happening

A public link has an optional expiration date (expiresAt). That date is only checked in checkLink on the /links/* entry points. When a visitor opens a link, a dedicated link user (USER_ROLE.LINK) is created and receives access and refresh cookies. After that, requests go through the normal authenticated API and the link is never checked again.

So once a link expired, an already open session kept working. The refresh token re-issues access tokens while keeping its original expiration (an absolute cap of 4h by default, measured from session creation), so the expired link kept access for up to that cap plus one access token lifetime (about 15m).

What changed

On token refresh, when the authenticated user is a link user (USER_ROLE.LINK), the refresh now loads that link and rejects the refresh if the link has expired. The check lives in AuthTokenRefreshStrategy, the single validation point shared by both refresh endpoints: POST /auth/refresh (web client) and POST /auth/token/refresh (desktop client).

The expiration comparison is the exact one already used by checkLink. It has been extracted into a small helper (isLinkExpired) so both paths use the same logic and cannot drift.

Effect: an expired link session stops being refreshed, so it ends within one access token lifetime (about 15m) instead of lasting until the refresh token cap.

What was intentionally not changed

  • No per-request check was added to the file API or any other authenticated route. The extra query runs only on refresh and only for link users. Regular users get no extra query and no behavior change.
  • limitAccess was left as is (one access equals one session), as agreed.
  • The access token strategy, the file API and SpaceGuard were not touched.

Notes

  • The link to link user relation is one to one. Each link creates its own dedicated link user, so the lookup by user id returns a single link.
  • The web client already handles a 401 from the refresh endpoint cleanly. The interceptor excludes the refresh call itself from triggering another refresh, so the session ends and the user is redirected with no refresh loop. No frontend change was needed.

How it was tested

  • Refresh path (guard and strategy): an expired link session is rejected, a link session with a future expiration passes, and a regular user is never looked up.
  • Expiration helper through the users manager: a past date rejects, a future date and no date pass.
  • New query: returns the link expiration, and returns null when the user has no link.
  • Full backend suite passes (1815 tests, no type errors). ESLint and Prettier pass. The backend build (nest build, tsc) reports no issues.
  • No end to end run against a database was done in this environment, since no database was available here. The behavior is covered by the unit tests at the refresh validation point.

This was discussed and agreed with the maintainer beforehand.

@cla-assistant

cla-assistant Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@johaven

johaven commented Oct 26, 2026

Copy link
Copy Markdown
Member

@tynianov1, thanks for the fix.
Could we avoid reading links.expiresAt from the database on every token refresh?

We already have link.expiresAt when the link user is initially authenticated. I suggest passing it to AuthManager.setCookies() as an optional absolute session expiration and capping each token lifetime:

remainingLinkLifetime = linkExpiresAt - currentTime

tokenExpiration = min(
  configuredTokenExpiration,
  remainingLinkLifetime
)

TOKEN_TYPES contains REFRESH, ACCESS, WS, and CSRF. Since tokenExpiration is used both for the JWT expiresIn value, where applicable, and the cookie maxAge, this would ensure that all cookies and tokens issued for the link session are bounded by link.expiresAt.

The same boundary should be preserved during refresh. The refresh flow already keeps the refresh token’s absolute exp and uses its remaining lifetime for refresh and WebSocket tokens. However, access tokens currently receive their full configured lifetime, so they should also be capped:

remainingSessionLifetime = refreshTokenExp - currentTime

accessTokenExpiration = min(
  configuredAccessTokenExpiration,
  remainingSessionLifetime
)

The CSRF cookie should use the same maximum session boundary for consistency.

For links without an expiration date, the existing configured lifetimes would remain unchanged.

This approach removes the additional database query on every refresh and makes the expiration established at authentication time an immutable security boundary. Extending the link later would not extend already-issued sessions; the link user would need to authenticate again.

…d of a refresh DB check

Cap every token and cookie of a link session to the link expiration at
authentication, so the whole session is bounded by the link itself with no
database read on refresh. The refresh token then carries that boundary, and
refreshing can never extend a session past it.

Also cap the access token and the CSRF cookie to the remaining refresh
lifetime on refresh for all users, so a refreshed access token never outlives
its session.
@tynianov1

Copy link
Copy Markdown
Contributor Author

hi @johaven

At link authentication I pass link.expiresAt to setCookies and cap every token and cookie to min(configured lifetime, expiresAt - now), using the same instant the existing check uses (new Date() >= expiresAt), so the refresh token carries the boundary and refresh needs no DB read. Access and CSRF are now also capped to the remaining refresh lifetime on refresh for all users.
1 open point - an already open websocket is only checked at handshake, so it stays connected until it reconnects even after its ws token expires. Do you want expired link sessions to drop live sockets too, or keep that for a separate change?

@johaven

johaven commented Oct 27, 2026

Copy link
Copy Markdown
Member

Thanks for the update! Let's handle WebSocket disconnection in a separate PR 😊

The CLA is back up and running, and I'll take a look at your PR soon!

@tynianov1

Copy link
Copy Markdown
Contributor Author

@johaven CLA website is still doesn't work for me. Is there anything else needed from my side for this PR?
Screenshot 2026-10-29 at 11 23 05

@johaven

johaven commented Oct 29, 2026

Copy link
Copy Markdown
Member

@tynianov1 Could you retry with CLA website, it seems ok now.

@tynianov1

Copy link
Copy Markdown
Contributor Author

@johaven should be done now

@johaven

johaven commented Oct 29, 2026

Copy link
Copy Markdown
Member

@tynianov1, I grouped the link expiration fixes into one commit. Expiration is now checked atomically when consuming an access, and no session is created once the expiration boundary has been reached. I also added coverage for expiration race conditions and updated the related documentation.

If this looks good to you, we can merge.

Preserve link session expiration bounds while incorporating the API token restrictions for link accounts.
@tynianov1

Copy link
Copy Markdown
Contributor Author

@johaven in my opinion it's looking good, we can merge it

@johaven
johaven merged commit c0148f0 into Sync-in:main Oct 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants