mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: let admins keep the refresh token valid on sign-out so Stalwart keeps the consent #1388
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user