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.
Environment
1.7.1(also reproduced on1.5.2before upgrading)OAUTH_ONLY: "true", backed by an external OIDC provider (Authentik) as the identity source for Stalwart JMAP authBug
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_SCOPESinlib/oauth/tokens.tsis hardcoded to:offline_accessis 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 cookieif (tokens.refresh_token || tokens.id_token)— sincerefresh_tokenis absent, that cookie is simply never set.stores/auth-store.ts's persisted state (partialize) deliberately excludesaccessToken/clientfrom localStorage, keeping onlyisAuthenticated/serverUrl/username/etc. So on every hard reload,checkAuth()seesisAuthenticated: truerestored butclient: null, and callsrefreshAccessToken()to reconstitute the session — by design, this should be a silent, invisible refresh.refreshAccessToken()callsPUT /api/auth/token, which reads the (never-set) refresh-token cookie, gets nothing, 401s, and the client callsmarkSessionExpired()+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_accesstoOAUTH_SCOPES:That's the whole fix on this repo's side — the IdP needs
offline_accessmapped 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.