From 50b3ddccecfd956fc3e7bd5095396ab55bfc11cc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:47:18 +0100 Subject: [PATCH] feat: let admins keep the refresh token valid on sign-out so Stalwart keeps the consent #1388 --- .env.example | 7 +++++ app/(main)/admin/_pages/auth.tsx | 1 + app/api/auth/token/route.ts | 7 +++-- hooks/use-config.ts | 2 ++ lib/__tests__/lite-config.test.ts | 6 +++++ lib/__tests__/lite-tokens.test.ts | 36 +++++++++++++++++++++++++ lib/__tests__/oauth-token-route.test.ts | 11 ++++++++ lib/admin/types.ts | 5 ++++ lib/auth/lite-tokens.ts | 10 ++++--- lib/config-validation.ts | 2 ++ lib/lite-config.ts | 1 + lib/oauth/end-session.ts | 5 ++++ scripts/lite/lib.mjs | 2 ++ stores/auth-store.ts | 6 ++--- 14 files changed, 92 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index c6eee95c6..2ef7d1cb8 100644 --- a/.env.example +++ b/.env.example @@ -86,6 +86,13 @@ JMAP_SERVER_URL=https://your-jmap-server.com # that should stay signed in. # OAUTH_END_SESSION=false +# Signing out revokes Bulwark's refresh token at the provider's revocation +# endpoint. Stalwart 1.0 then also removes Bulwark from the user's connected +# applications and asks for consent again on the next sign-in. Set to false +# to keep the consent; the refresh token then stays valid at the provider +# until it expires. +# OAUTH_REVOKE_ON_SIGN_OUT=false + # The provider's logout URL, for providers whose discovery document has no # end_session_endpoint (Stalwart's built-in provider, for example). When set, # it is used instead of the discovered endpoint and gets the same parameters: diff --git a/app/(main)/admin/_pages/auth.tsx b/app/(main)/admin/_pages/auth.tsx index 6f0df1015..9bfdcd684 100644 --- a/app/(main)/admin/_pages/auth.tsx +++ b/app/(main)/admin/_pages/auth.tsx @@ -302,6 +302,7 @@ export function AuthPage() { + diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index e31c66618..389599afa 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -26,6 +26,7 @@ import { getPostLogoutRedirectUri, idTokenCookieName, isEndSessionEnabled, + isRevokeOnSignOutEnabled, storeIdToken, } from '@/lib/oauth/end-session'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; @@ -341,15 +342,17 @@ async function signOutSlot( const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null; const idToken = cookieStore.get(idTokenCookieName(slot))?.value; + // With revocation off (#1388) the token is only dropped from the browser. + const tokenToRevoke = refreshToken && isRevokeOnSignOutEnabled() ? refreshToken : undefined; let endSessionUrl: string | null = null; - if (refreshToken || endSession) { + if (tokenToRevoke || endSession) { const metadata = await getMetadata(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => { logger.warn('Failed to discover OAuth metadata during logout', { error: err instanceof Error ? err.message : 'Unknown error', }); return null; }); - if (refreshToken) await revokeRefreshToken(refreshToken, serverId, metadata); + if (tokenToRevoke) await revokeRefreshToken(tokenToRevoke, serverId, metadata); if (endSession) endSessionUrl = endSessionUrlFor(metadata, serverId, idToken); } diff --git a/hooks/use-config.ts b/hooks/use-config.ts index c6fe4908c..545e7d904 100644 --- a/hooks/use-config.ts +++ b/hooks/use-config.ts @@ -56,6 +56,8 @@ export interface ConfigData { signLanguageUrl: string; /** Stay off every built-in outside host (#903; lib/outgoing-hosts.ts). */ noThirdPartyTraffic: boolean; + /** Lite only: whether sign-out revokes the refresh token (#1388). The server build decides in its token route. */ + oauthRevokeOnSignOut?: boolean; } interface AppConfig extends ConfigData { diff --git a/lib/__tests__/lite-config.test.ts b/lib/__tests__/lite-config.test.ts index f2ca0f9b5..216200916 100644 --- a/lib/__tests__/lite-config.test.ts +++ b/lib/__tests__/lite-config.test.ts @@ -14,6 +14,12 @@ describe('applyLiteConfig (config.json for the static build)', () => { expect(config.loginLogoLightUrl).toBe('/branding/Bulwark_Logo_Color.svg'); expect(config.jmapServers).toEqual([]); expect(config.demoMode).toBe(false); + expect(config.oauthRevokeOnSignOut).toBe(true); + }); + + it('lets config.json keep refresh tokens valid on sign-out (#1388)', () => { + expect(applyLiteConfig({ oauthRevokeOnSignOut: false }).oauthRevokeOnSignOut).toBe(false); + expect(applyLiteConfig({ oauthRevokeOnSignOut: 'no' }).oauthRevokeOnSignOut).toBe(true); }); it('allows a custom endpoint by default only when no server is configured', () => { diff --git a/lib/__tests__/lite-tokens.test.ts b/lib/__tests__/lite-tokens.test.ts index 1e2662b4d..b9e6d030d 100644 --- a/lib/__tests__/lite-tokens.test.ts +++ b/lib/__tests__/lite-tokens.test.ts @@ -8,6 +8,8 @@ import { liteTokenLogin, probeLiteTokenLogin, readLiteAccessToken, + revokeAllLiteSessions, + revokeLiteSlot, readLiteBasicSession, readLiteRefreshToken, resetLiteProbeCache, @@ -382,3 +384,37 @@ describe('probeLiteTokenLogin', () => { expect(err.status).toBe(404); }); }); + +describe('lite sign-out revocation (#1388)', () => { + const REVOKE = `${SERVER}/auth/revoke`; + const fetchMock = vi.fn(async () => new Response(null, { status: 200 })); + + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + fetchMock.mockClear(); + vi.stubGlobal('fetch', fetchMock); + saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'a', refreshToken: 'r0', revocationEndpoint: REVOKE }, true); + saveLiteRefreshToken(1, { serverUrl: SERVER, username: 'b', refreshToken: 'r1', revocationEndpoint: REVOKE }, false); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('revokes the refresh token by default', async () => { + await revokeLiteSlot(0); + + expect(fetchMock).toHaveBeenCalledWith(REVOKE, expect.objectContaining({ method: 'POST' })); + expect(readLiteRefreshToken(0)).toBeNull(); + }); + + it('only forgets the tokens when revocation is turned off', async () => { + await revokeLiteSlot(0, false); + await revokeAllLiteSessions(false); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(readLiteRefreshToken(0)).toBeNull(); + expect(readLiteRefreshToken(1)).toBeNull(); + }); +}); diff --git a/lib/__tests__/oauth-token-route.test.ts b/lib/__tests__/oauth-token-route.test.ts index 3cf22530c..c12d8185a 100644 --- a/lib/__tests__/oauth-token-route.test.ts +++ b/lib/__tests__/oauth-token-route.test.ts @@ -558,6 +558,17 @@ describe('oauth token route - sign-out (#905)', () => { expect(cookieStore.deletedPaths.get('jmap_idt')).toBe('/api/auth/token'); }); + it('keeps the grant at the provider when revocation is turned off (#1388)', async () => { + configValues.oauthRevokeOnSignOut = false; + seedSlot(0); + + await callRoute('DELETE', { slot: '0' }); + + expect(fetchMock).not.toHaveBeenCalled(); + // Signed out all the same: the browser holds no token afterwards. + for (const name of ['jmap_rt', 'jmap_at', 'jmap_idt']) expect(cookieStore.get(name)).toBeUndefined(); + }); + it('identifies the client alone when no id token was kept', async () => { seedSlot(0, { idToken: null }); diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 09246ba32..3908ce360 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -320,6 +320,11 @@ export const CONFIG_ENV_MAP: Record { /** * Sign a slot out: forget its credentials at once, then revoke its refresh * token so a copy taken from this browser's storage stops working too. + * `revoke: false` (config.json `oauthRevokeOnSignOut`) only forgets it, so + * Stalwart 1.0 keeps the consent for the next sign-in (#1388). */ -export async function revokeLiteSlot(slot: number): Promise { +export async function revokeLiteSlot(slot: number, revoke = true): Promise { const entry = readLiteRefreshToken(slot); clearLiteSlot(slot); - if (entry) await revokeRefreshToken(entry); + if (entry && revoke) await revokeRefreshToken(entry); } /** {@link revokeLiteSlot} for every slot. */ -export async function revokeAllLiteSessions(): Promise { +export async function revokeAllLiteSessions(revoke = true): Promise { const entries: StoredRefreshToken[] = []; for (const kind of ['local', 'session'] as const) { const store = storage(kind); @@ -322,7 +324,7 @@ export async function revokeAllLiteSessions(): Promise { } } clearAllLiteSessions(); - await Promise.all(entries.map(revokeRefreshToken)); + if (revoke) await Promise.all(entries.map(revokeRefreshToken)); } export function clearAllLiteSessions(): void { diff --git a/lib/config-validation.ts b/lib/config-validation.ts index a9d1a7b4a..87ad88ce0 100644 --- a/lib/config-validation.ts +++ b/lib/config-validation.ts @@ -71,6 +71,8 @@ const configValidators = { easyReadUrl: isString, signLanguageUrl: isString, noThirdPartyTraffic: isBoolean, + // Lite only (lib/lite-config.ts); the server's response leaves it out. + oauthRevokeOnSignOut: (value): value is boolean | undefined => value === undefined || typeof value === 'boolean', } satisfies Validators; /** diff --git a/lib/lite-config.ts b/lib/lite-config.ts index 18047896b..00dbb5a80 100644 --- a/lib/lite-config.ts +++ b/lib/lite-config.ts @@ -104,6 +104,7 @@ export function applyLiteConfig(raw: unknown, defaults?: { jmapServerUrl?: strin signLanguageUrl: httpUrlOrEmpty(input.signLanguageUrl), // Same browser-side switches as the server build (lib/outgoing-hosts.ts). noThirdPartyTraffic: parseNoThirdPartyValue(input.noThirdPartyTraffic), + oauthRevokeOnSignOut: bool(input.oauthRevokeOnSignOut, true), ...LITE_FORCED_FLAGS, }; } diff --git a/lib/oauth/end-session.ts b/lib/oauth/end-session.ts index 17a8a0fe9..e486ed200 100644 --- a/lib/oauth/end-session.ts +++ b/lib/oauth/end-session.ts @@ -74,6 +74,11 @@ export function isEndSessionEnabled(): boolean { return configManager.get('oauthEndSession', true) !== false; } +/** Whether signing out revokes the refresh token at the provider (OAUTH_REVOKE_ON_SIGN_OUT). */ +export function isRevokeOnSignOutEnabled(): boolean { + return configManager.get('oauthRevokeOnSignOut', true) !== false; +} + function isLoopbackHost(hostname: string): boolean { return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; } diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs index cd16d0ee4..af0eaacb6 100644 --- a/scripts/lite/lib.mjs +++ b/scripts/lite/lib.mjs @@ -811,6 +811,8 @@ ${demoMode ? "Demo mode is ON: the login page offers a built-in demo account and Sign-ins are otherwise remembered; \`loginShowRememberMe: true\` lets people choose. - \`noThirdPartyTraffic\`: \`true\` keeps the browser off every built-in outside host (no Google Maps links on event addresses; see the third-party traffic docs). + - \`oauthRevokeOnSignOut\`: \`false\` keeps the refresh token valid at the server on + sign-out, so Stalwart 1.0 does not ask for consent again on the next sign-in. Optional keys: \`demoMode\`, \`jmapServers\`, \`jmapServerAutoPickByDomain\`, the login logo/company/link keys and \`loginShow*\` toggles (same names as the Docker env vars, camelCased). 3. Allow the browser to talk to the mail server (CORS). In Stalwart, turn on the \`usePermissiveCors\` setting of the \`Http\` object: in the web admin diff --git a/stores/auth-store.ts b/stores/auth-store.ts index d8b45f126..1cd5f21f2 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -11,7 +11,7 @@ import { useFilterStore } from './filter-store'; import { useSettingsStore } from './settings-store'; import { useAccountStore, type AccountEntry } from './account-store'; import { fetchPrincipalDisplayName } from '@/lib/stalwart/principal'; -import { fetchConfig } from '@/hooks/use-config'; +import { fetchConfig, getCachedConfig } from '@/hooks/use-config'; import { debug } from '@/lib/debug'; import { setServerAuthIssue } from '@/lib/server-auth-status'; import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; @@ -691,7 +691,7 @@ function clearSlotCredentials(slot: number, includeToken: boolean, endSession = closingSlots.add(slot); return clearClosingSlots(new Set([slot]), async () => { if (IS_LITE) { - await revokeLiteSlot(slot); + await revokeLiteSlot(slot, getCachedConfig()?.oauthRevokeOnSignOut !== false); return null; } const [, token] = await Promise.all([ @@ -711,7 +711,7 @@ function clearAllCredentials(endSessionSlot: number | null): Promise { if (IS_LITE) { - await revokeAllLiteSessions(); + await revokeAllLiteSessions(getCachedConfig()?.oauthRevokeOnSignOut !== false); return null; } const endSession = endSessionSlot === null ? '' : `&end_session_slot=${endSessionSlot}`;