Skip to content

feat(oidc): refresh the access token in-place when it expires - #10371

Open
perfectra1n wants to merge 2 commits into
mainfrom
feat/oidc-token-refresh
Open

feat(oidc): refresh the access token in-place when it expires#10371
perfectra1n wants to merge 2 commits into
mainfrom
feat/oidc-token-refresh

Conversation

@perfectra1n

@perfectra1n perfectra1n commented Jul 3, 2026

Copy link
Copy Markdown
Member

What this does

Adds an Express middleware, refreshOidcTokenIfNeeded, that refreshes an expired OIDC access token in place using the stored refresh token, so a long-lived Trilium SSO session can keep a valid provider token instead of silently going stale after the provider's (often 1-hour) access-token TTL.

Note

Stacked on #9635. This PR is based on feat/fix-oidc-take1 (#9635) and its diff shows only the refresh-token changes. #9635 makes the OIDC scope configurable, which is the prerequisite for requesting offline_access. Merge #9635 first (or retarget this to main after it lands).

This was split out of the original combined OIDC branch at the maintainer's request so the hardening (#9635) and the refresh feature can be reviewed independently.

How it works

app.ts mounts refreshOidcTokenIfNeeded immediately after the reactive OIDC middleware, so req.oidc is populated when it runs. The middleware:

  • No-ops unless the user is OIDC-authenticated and the access token is expired and a refresh token is present. In the default configuration (no offline_access) the provider never issues a refresh token, so this is completely inert.
  • Calls accessToken.refresh() — the library exposes refresh() on the AccessToken object (it performs the refresh_token grant and updates the appSession), not on req.oidc.
  • On invalid_grant (RFC 6749 — refresh token revoked / expired / consent withdrawn): marks the local session loggedIn = false, so the downstream checkAuth redirects to /login. This is the only way Trilium learns of an upstream revocation between logins.
  • On transient errors (network blip, IdP 5xx): logs and continues on the existing session — a provider outage must not bounce a logged-in user, since trilium.sid is the source of truth for "is this user signed in."
  • Coalesces concurrent refreshes: expired-token requests sharing one trilium.sid share a single in-flight refresh promise, so refresh-token rotation (Authentik/Auth0/Okta default) doesn't race — the first request consumes the RT and the rest piggy-back instead of racing on a now-stale token. The rotated tokens are then copied into each piggybacking request's appSession: every request decodes its own copy of the (stateless, rolling) session cookie and the library re-encrypts that copy on every response, so without the copy a piggybacker's stale Set-Cookie could clobber the initiator's refreshed one and force invalid_grant on the next request (caught by Greptile in review).

Requesting offline access (opt-in)

Refresh tokens are only issued when the operator adds offline_access to oauthScope. How that opt-in reaches the provider is issuer-aware (refined from review feedback):

  • Spec-compliant providers (Authelia, Authentik, Keycloak, Okta, Auth0, …): the offline_access scope itself is the refresh-token request and is passed through as-is. No extra parameters — unconditionally sending prompt=consent would force the consent screen on every single login.
  • Google: does not accept offline_access as a scope (fails with invalid_scope), so the scope token is stripped and replaced with Google's own mechanism: access_type=offline + prompt=consent (without which Google only issues a refresh token on the very first consent).

Without the opt-in, neither is sent — the default sign-in flow is byte-for-byte identical to main, matching its earlier removal of the unconditional offline params (c23d567). Documented in the "Signing in with OpenID Connect" User Guide page.

Note during extraction

The original version of this middleware called req.oidc.refresh(), which does not exist — the library puts refresh() on the AccessToken, so it would have thrown req.oidc.refresh is not a function the first time a token expired. Rebasing onto current main and typechecking caught it; fixed here to accessToken.refresh(), with a unit test that exercises the success path against the correct API.

Tests

open_id.spec.ts covers all middleware branches (not authenticated, not logged in, token still valid, no refresh token, successful refresh, transient soft-fail keeps the session, invalid_grant forces re-auth, concurrent coalescing, rotated-token propagation into piggybackers) plus the per-issuer offline-access gating in generateOAuthConfig (spec provider passes the scope through with no params; Google strips the scope and gets access_type/prompt; no opt-in → untouched). All server open_id/config unit tests pass (68/68); typecheck is clean.

@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Jul 3, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces a new middleware, refreshOidcTokenIfNeeded, to automatically refresh expired OIDC access tokens when the offline_access scope is configured. It also includes comprehensive unit tests and updates the user documentation. The reviewer suggested restricting Google-specific authorization parameters (access_type=offline and prompt=consent) to only be sent when the provider is Google, as unconditionally sending them causes disruptive consent prompts on other standard OIDC providers. The documentation should also be updated to reflect this behavior.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread apps/server/src/services/open_id.ts Outdated
Comment on lines +233 to +235
const offlineAccessParams = wantsOfflineAccess
? { access_type: "offline", prompt: "consent" }
: {};

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.

medium

The access_type=offline and prompt=consent parameters are Google-specific workarounds to force Google to return a refresh token on subsequent logins. However, for standard OpenID Connect providers (like Keycloak, Authentik, Okta, or Auth0), requesting the offline_access scope is sufficient to obtain a refresh token. Unconditionally sending prompt=consent to these standard providers forces the user to see the consent screen on every single login, which is a disruptive user experience.

We should restrict these parameters to only be sent when the configured issuer is Google.

    const issuer = config.MultiFactorAuthentication.oauthIssuerBaseUrl.replace(/\/+$/, "");
    const isGoogle = issuer === GOOGLE_ISSUER;
    const offlineAccessParams = (wantsOfflineAccess && isGoogle)
        ? { access_type: "offline", prompt: "consent" }
        : {};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Implemented in 218c7c9, with one addition beyond the suggestion: when the issuer is Google, offline_access is also stripped from the scope string, since Google rejects it as invalid_scope — leaving it in would break the authorization request for the very users opting in. So: spec providers get the scope passed through with no extra params; Google gets the scope minus offline_access plus access_type=offline/prompt=consent. Reuses the existing GOOGLE_ISSUER comparison (extracted as isGoogleIssuer(), shared with resolveClientAuthMethod).

1. Add `offline_access` to the `oauthScope` setting (e.g. `oauthScope=openid profile email offline_access`, or the `TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHSCOPE` / `TRILIUM_OAUTH_SCOPE` environment variable).
2. Restart the server and reconnect your account so the provider issues a refresh token.

When offline access is requested, Trilium additionally sends `access_type=offline` and `prompt=consent` to the authorization endpoint (required for Google to issue and re-issue a refresh token). These parameters are **not** sent otherwise, so the normal sign-in flow is unaffected.

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.

medium

If we restrict access_type=offline and prompt=consent to Google only (as suggested in the code review), we should update this documentation to clarify that these parameters are only sent when the provider is Google, preventing confusion for users of other OIDC providers.

Suggested change
When offline access is requested, Trilium additionally sends `access_type=offline` and `prompt=consent` to the authorization endpoint (required for Google to issue and re-issue a refresh token). These parameters are **not** sent otherwise, so the normal sign-in flow is unaffected.
When offline access is requested and the provider is Google, Trilium additionally sends access_type=offline and prompt=consent to the authorization endpoint (required for Google to issue and re-issue a refresh token). For other providers, these Google-specific parameters are omitted to avoid unnecessary consent prompts.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated in 218c7c9 to describe the per-issuer behavior, including the Google scope-stripping.

@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an Express middleware (refreshOidcTokenIfNeeded) that transparently refreshes expired OIDC access tokens using a stored refresh token, preventing stale provider tokens after the typical 1-hour IdP TTL. It also handles Google's non-standard offline-access mechanism by translating offline_access scope into access_type=offline + prompt=consent.

  • Refresh middleware: Intercepts requests with an expired access token and a refresh token present, coalesces concurrent refreshes for the same trilium.sid into a single IdP call, then copies the rotated token fields into each piggybacking request's appSession so rolling-session cookies stay consistent; hard-fails on invalid_grant (forces re-auth), soft-fails on transient errors (session preserved).
  • Google issuer handling: Extracts a new isGoogleIssuer() helper and wires it into generateOAuthConfig() to strip offline_access from the scope and substitute access_type=offline + prompt=consent when Google is the configured issuer.
  • Tests: Comprehensive coverage across all middleware branches and the Google-specific offline-access gating (67 server unit tests pass).

Confidence Score: 5/5

This PR is safe to merge. The middleware is a no-op when offline_access is absent from the configured scope, which is the default, so existing deployments are unaffected.

The coalescing and token-propagation logic is correctly structured for Node.js's single-threaded microtask model, the invalid_grant hard-fail and transient soft-fail paths are both tested, and the Google-specific scope translation is clearly delimited. No defects were found in the changed paths.

No files require special attention. The open_id.ts middleware is the substantive change; its unit tests cover all branches.

Important Files Changed

Filename Overview
apps/server/src/services/open_id.ts Core implementation: adds refreshOidcTokenIfNeeded middleware with coalescing via inFlightRefreshes, token snapshot/propagation for piggybacking requests, and Google-specific offline-access translation; logic is well-structured with correct error handling paths.
apps/server/src/services/open_id.spec.ts Adds thorough test coverage for all middleware branches (no-op paths, success, soft-fail, invalid_grant force-reauth, concurrent coalescing, token propagation to piggybackers) and the Google offline-access scope gating.
apps/server/src/app.ts Mounts refreshOidcTokenIfNeeded immediately after the reactive OIDC middleware so req.oidc is populated; change is minimal and correctly positioned.
docs/User Guide/User Guide/Installation & Setup/Server Installation/Signing in with OpenID Connect.md Adds documentation for the new offline-access opt-in, covering spec-compliant providers, Google's special handling, and the refresh/revocation behaviour.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser
    participant ReactiveOIDC as createReactiveOidcMiddleware
    participant Refresh as refreshOidcTokenIfNeeded
    participant InFlight as inFlightRefreshes (Map)
    participant IdP as Identity Provider
    participant Downstream as checkAuth / Route

    Browser->>ReactiveOIDC: HTTP request (trilium.sid present)
    ReactiveOIDC->>Refresh: next() — req.oidc populated
    
    alt Not OIDC-authenticated, not loggedIn, or token still valid, or no refreshToken
        Refresh->>Downstream: next() immediately (no-op)
    else Access token expired AND refreshToken present
        Refresh->>InFlight: get(sessionId)
        
        alt No in-flight refresh for this session
            Refresh->>InFlight: set(sessionId, promise)
            Refresh->>IdP: accessToken.refresh()
            IdP-->>Refresh: resolve (tokens written to appSession)
            Refresh->>InFlight: delete(sessionId)
            
            alt Success
                Refresh->>Downstream: copy refreshed tokens to own appSession, next()
            else invalid_grant
                Refresh->>Refresh: "req.session.loggedIn = false"
                Refresh->>Downstream: next() — checkAuth redirects to /login
            else Transient error (network/5xx)
                Refresh->>Downstream: next() (session preserved)
            end
        else In-flight refresh exists (same sessionId)
            Refresh->>Refresh: piggyback on existing promise
            Refresh->>Downstream: copy refreshed tokens from snapshot, next()
        end
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Browser
    participant ReactiveOIDC as createReactiveOidcMiddleware
    participant Refresh as refreshOidcTokenIfNeeded
    participant InFlight as inFlightRefreshes (Map)
    participant IdP as Identity Provider
    participant Downstream as checkAuth / Route

    Browser->>ReactiveOIDC: HTTP request (trilium.sid present)
    ReactiveOIDC->>Refresh: next() — req.oidc populated
    
    alt Not OIDC-authenticated, not loggedIn, or token still valid, or no refreshToken
        Refresh->>Downstream: next() immediately (no-op)
    else Access token expired AND refreshToken present
        Refresh->>InFlight: get(sessionId)
        
        alt No in-flight refresh for this session
            Refresh->>InFlight: set(sessionId, promise)
            Refresh->>IdP: accessToken.refresh()
            IdP-->>Refresh: resolve (tokens written to appSession)
            Refresh->>InFlight: delete(sessionId)
            
            alt Success
                Refresh->>Downstream: copy refreshed tokens to own appSession, next()
            else invalid_grant
                Refresh->>Refresh: "req.session.loggedIn = false"
                Refresh->>Downstream: next() — checkAuth redirects to /login
            else Transient error (network/5xx)
                Refresh->>Downstream: next() (session preserved)
            end
        else In-flight refresh exists (same sessionId)
            Refresh->>Refresh: piggyback on existing promise
            Refresh->>Downstream: copy refreshed tokens from snapshot, next()
        end
    end
Loading

Reviews (2): Last reviewed commit: "feat(oidc): address review feedback on o..." | Re-trigger Greptile

Comment thread apps/server/src/services/open_id.ts
Comment on lines +148 to +149
if (oauthError === "invalid_grant") {
getLog().info(`OIDC refresh token rejected by IdP (invalid_grant): ${message}. Forcing re-authentication.`);

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.

P2 invalid_grant from the IdP signals a deliberate revocation (admin-forced logout, consent withdrawal, IdP-side session invalidation). Logging it at info buries a security-relevant event alongside routine operational chatter; warn makes it visible to operators monitoring logs for anomalies without requiring a log-level change.

Suggested change
if (oauthError === "invalid_grant") {
getLog().info(`OIDC refresh token rejected by IdP (invalid_grant): ${message}. Forcing re-authentication.`);
if (oauthError === "invalid_grant") {
getLog().warn(`OIDC refresh token rejected by IdP (invalid_grant): ${message}. Forcing re-authentication.`);

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not applied: the core logger (packages/trilium-core/src/services/log.ts) has no warn() method — only log/info/error — so the suggested code doesn't compile. The repo also logs comparable security-relevant OAuth rejections at info (e.g. OAuth login rejected: the authenticated account is not the enrolled one in afterCallback), so info matches the established convention here. Happy to bump to error if the maintainer prefers louder revocation events, but warn isn't available.

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.

Fair point — if warn() doesn't exist on the logger, my suggestion would break the build. And with info being the established convention for OAuth rejection events in this codebase, info is the right call here. Withdrawing this suggestion.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Adds refreshOidcTokenIfNeeded middleware (mounted after the reactive OIDC
middleware in app.ts) that refreshes an expired OIDC access token using the
stored refresh token:

- No-ops unless the user is OIDC-authenticated, the access token is expired,
  and a refresh token is present (so it is inert for the default flow that
  never requests offline_access).
- Calls accessToken.refresh() (the library exposes refresh() on the
  AccessToken, not on req.oidc).
- invalid_grant -> mark the session not-logged-in so checkAuth redirects to
  /login; transient errors -> log and continue on the local session.
- Concurrent expired-token requests for one trilium.sid share a single
  in-flight refresh promise, avoiding refresh-token-rotation races.

generateOAuthConfig now re-adds access_type=offline + prompt=consent, but only
when oauthScope includes offline_access, so the default sign-in flow is
unchanged and offline access is requested only when it will actually be used.

Requires #9635 (configurable oauthScope). Adds middleware + gating unit tests
and User Guide documentation.
- Offline access is now expressed per-issuer (gemini-code-assist): for
  spec-compliant providers the offline_access scope is passed through as-is
  and no extra params are sent (prompt=consent would force the consent screen
  on every login); for Google — which rejects offline_access as invalid_scope
  — the scope token is stripped and replaced with access_type=offline +
  prompt=consent.
- Coalesced refreshes now propagate the rotated tokens into piggybacking
  requests' appSession (greptile P1): each request decodes its own cookie
  copy and the library re-encrypts it on every (rolling) response, so a
  piggybacker's stale Set-Cookie could clobber the initiator's refreshed one
  and hit invalid_grant on the next request. Verified against the library's
  context.js refresh() and appSession.js res.end hook.
@perfectra1n
perfectra1n force-pushed the feat/oidc-token-refresh branch from 9ed4426 to 218c7c9 Compare July 3, 2026 22:59
@perfectra1n
perfectra1n requested a review from eliandoran August 12, 2026 19:08
@perfectra1n

Copy link
Copy Markdown
Member Author

I'm still getting signed out like once every hour or so when using my provider...would it be possible to merge this?

Base automatically changed from feat/fix-oidc-take1 to main August 13, 2026 15:46
@maphew

maphew commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Tested this PR headless against a live Keycloak (35s access-token lifespan, refresh-token rotation on: revokeRefreshToken=true, maxReuse=0), using the IdP event log as ground truth.

What works, verified live:

  • Expired token is refreshed in place; session survives (single REFRESH_TOKEN event per expiry).
  • Rotation is handled correctly: the new refresh token is stored and the next cycle succeeds; coalesced requests receive the rotated tokens too.
  • 8 parallel requests at expiry produce exactly one refresh; all return 200.
  • Revoked refresh token fails gracefully: 401 plus re-login redirect in 22ms, no 500 or hang.
  • IdP outage: request stalls oauthHttpTimeout (30s) once, then continues on the expired token; session survives.

Two findings maintainers may want to weigh:

  1. Near-miss race: a request carrying the stale cookie that arrives just after the coalesced refresh completes (40ms later in my test) replays the consumed refresh token, hits invalid_grant, and force-logs-out the whole session; Keycloak reuse detection also revokes the fresh tokens. The in-flight map entry is cleared when the token call resolves, not when the initiator's Set-Cookie reaches the client, so the window is response-latency wide. Main never kills a session, so this is a new, though narrow, failure mode.

  2. The "no refresh token without offline_access, so this is a no-op" assumption does not hold for Keycloak-family providers: they issue a session-bound refresh token on the default scope, the middleware refreshes with it, and once the SSO idle timeout (default 30 min) lapses the session is forced to re-auth, where current main kept its 21-day local session. Arguably correct SSO behavior, but worth documenting.

Also: base is 3166 commits behind main and conflicting; PR 10348 touches open_id.ts too.

claude-fable-5-high on behalf of matt wilkie

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-conflicts size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants