Skip to content

OAUTH_ONLY sessions don't survive a page reload — offline_access scope is never requested, so no refresh token is issued #104

Description

@rriaz6601

Environment

  • jmap-webmail 1.7.1 (also reproduced on 1.5.2 before upgrading)
  • OAUTH_ONLY: "true", backed by an external OIDC provider (Authentik) as the identity source for Stalwart JMAP auth
  • Reverse-proxied, HTTPS

Bug

Any hard page reload (or the app's own periodic token-refresh timer, before the access token actually expires) logs the user out and forces a fresh OAuth login, even though the IdP's own SSO session is still fully valid. This happens roughly every reload, and independently every time the access token nears expiry during normal use.

Root cause

OAUTH_SCOPES in lib/oauth/tokens.ts is hardcoded to:

export const OAUTH_SCOPES = 'openid email profile';

offline_access is never requested and there's no env var to add it. Without that scope, the IdP (at least Authentik, likely most standards-compliant OIDC providers) never issues a refresh token.

Downstream effects of that:

  • app/api/auth/token/route.ts's token-exchange handler only sets the refresh-token cookie if (tokens.refresh_token || tokens.id_token) — since refresh_token is absent, that cookie is simply never set.
  • stores/auth-store.ts's persisted state (partialize) deliberately excludes accessToken/client from localStorage, keeping only isAuthenticated/serverUrl/username/etc. So on every hard reload, checkAuth() sees isAuthenticated: true restored but client: null, and calls refreshAccessToken() to reconstitute the session — by design, this should be a silent, invisible refresh.
  • refreshAccessToken() calls PUT /api/auth/token, which reads the (never-set) refresh-token cookie, gets nothing, 401s, and the client calls markSessionExpired() + logout(), wiping localStorage and forcing a full re-login.

So the app's own reload-recovery path is sound in design — it's just structurally guaranteed to fail because the token it needs was never requested in the first place.

Proposed fix

Add offline_access to OAUTH_SCOPES:

export const OAUTH_SCOPES = 'openid email profile offline_access';

That's the whole fix on this repo's side — the IdP needs offline_access mapped to its OAuth2/OIDC provider config to actually return a refresh token for it (already true for a standard Authentik OAuth2Provider, and standard for most OIDC IdPs), so no other client-side changes should be needed.

Happy to open a PR with this change if that's welcome — let me know.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions