feat: let admins keep the refresh token valid on sign-out so Stalwart keeps the consent #1388

This commit is contained in:
Linus Rath
2026-10-29 20:47:18 +01:00
parent f1d6b3b815
commit 50b3ddccec
14 changed files with 92 additions and 9 deletions
+7
View File
@@ -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:
+1
View File
@@ -302,6 +302,7 @@ export function AuthPage() {
<SettingsSection id="sso" title="Single sign-on">
<ConfigToggleItem label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="End provider session on sign-out" description="Signing out of an SSO account also signs out of the identity provider, when it advertises an end_session_endpoint or a logout URL is set below. Turn off if the provider is shared with apps that should stay signed in." configKey="oauthEndSession" value={currentValue('oauthEndSession') as boolean} source={config.oauthEndSession?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Revoke tokens on sign-out" description="Signing out asks the identity provider to revoke Bulwark's refresh token. On Stalwart 1.0 this also removes Bulwark from the user's connected applications, so the next sign-in asks for consent again. Turn off to keep the consent; the token then stays valid at the provider until it expires." configKey="oauthRevokeOnSignOut" value={currentValue('oauthRevokeOnSignOut') as boolean} source={config.oauthRevokeOnSignOut?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigTextItem label="Provider logout URL" description="For providers whose discovery has no end_session_endpoint, such as Stalwart. Used instead of the discovered endpoint when set, with the same parameters. Must be https. Applies to the global OAuth settings; servers in the server list set their own." configKey="oauthEndSessionUrl" value={currentValue('oauthEndSessionUrl') as string} source={config.oauthEndSessionUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com/logout" wide />
<ConfigTextItem label="Post-logout redirect URI" description="Where the provider sends users after signing them out. Register it with the provider as a post-logout redirect URI first, or it will refuse the logout. Leave blank to stay on the provider's signed-out page." configKey="oauthPostLogoutRedirectUri" value={currentValue('oauthPostLogoutRedirectUri') as string} source={config.oauthPostLogoutRedirectUri?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/en/login" wide />
</SettingsSection>
+5 -2
View File
@@ -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);
}
+2
View File
@@ -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 {
+6
View File
@@ -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', () => {
+36
View File
@@ -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();
});
});
+11
View File
@@ -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 });
+5
View File
@@ -320,6 +320,11 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
// through its end_session_endpoint (#905). Off keeps the provider signed in,
// for providers shared with other apps that should stay signed in.
oauthEndSession: { envVar: 'OAUTH_END_SESSION', type: 'boolean', defaultValue: true },
// Signing out revokes the refresh token at the provider's revocation
// endpoint. Off only drops it from the browser, so the provider keeps the
// grant: Stalwart 1.0 then skips the consent screen on the next sign-in
// (#1388), and the token stays valid at the provider until it expires.
oauthRevokeOnSignOut: { envVar: 'OAUTH_REVOKE_ON_SIGN_OUT', type: 'boolean', defaultValue: true },
// The provider's logout URL, for providers whose discovery document has no
// end_session_endpoint (#1279). Used instead of the discovered one when set.
// Applies to the global provider only; JMAP_SERVERS entries set their own
+6 -4
View File
@@ -297,15 +297,17 @@ async function revokeRefreshToken(entry: StoredRefreshToken): Promise<void> {
/**
* 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<void> {
export async function revokeLiteSlot(slot: number, revoke = true): Promise<void> {
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<void> {
export async function revokeAllLiteSessions(revoke = true): Promise<void> {
const entries: StoredRefreshToken[] = [];
for (const kind of ['local', 'session'] as const) {
const store = storage(kind);
@@ -322,7 +324,7 @@ export async function revokeAllLiteSessions(): Promise<void> {
}
}
clearAllLiteSessions();
await Promise.all(entries.map(revokeRefreshToken));
if (revoke) await Promise.all(entries.map(revokeRefreshToken));
}
export function clearAllLiteSessions(): void {
+2
View File
@@ -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<ConfigData>;
/**
+1
View File
@@ -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,
};
}
+5
View File
@@ -74,6 +74,11 @@ export function isEndSessionEnabled(): boolean {
return configManager.get<boolean>('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<boolean>('oauthRevokeOnSignOut', true) !== false;
}
function isLoopbackHost(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
}
+2
View File
@@ -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
+3 -3
View File
@@ -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<string | nu
for (const slot of slots) closingSlots.add(slot);
return clearClosingSlots(slots, async () => {
if (IS_LITE) {
await revokeAllLiteSessions();
await revokeAllLiteSessions(getCachedConfig()?.oauthRevokeOnSignOut !== false);
return null;
}
const endSession = endSessionSlot === null ? '' : `&end_session_slot=${endSessionSlot}`;