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}`;