From 4adbad25f6150f213fb4ed352afaaca1efcd450d Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 18 Oct 2026 13:46:48 +0200
Subject: [PATCH] feat: end the provider session through a configured logout
URL #1279
---
.env.example | 16 +++++--
.../admin/_tabs/_jmap-servers-section.tsx | 22 ++++++++-
app/(main)/admin/_tabs/auth.tsx | 3 +-
app/api/auth/token/route.ts | 19 ++++++--
lib/__tests__/oauth-issuer-precedence.test.ts | 34 ++++++++++++++
lib/__tests__/oauth-token-route.test.ts | 46 +++++++++++++++++++
lib/admin/jmap-servers.ts | 7 ++-
lib/admin/types.ts | 5 ++
lib/oauth/end-session.ts | 6 ++-
lib/oauth/token-exchange.ts | 7 ++-
10 files changed, 150 insertions(+), 15 deletions(-)
diff --git a/.env.example b/.env.example
index 96245b1a0..f5f46bb39 100644
--- a/.env.example
+++ b/.env.example
@@ -30,7 +30,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Prefer configuring this from the admin dashboard - the env form exists for
# stateless deployments. An "oauth" block with both "clientId" and
# "buttonLabel" gives that server its own sign-in button on the login page,
-# even while OAuth is off globally.
+# even while OAuth is off globally. "endSessionUrl" in the "oauth" block is
+# that server's OAUTH_END_SESSION_URL (see below).
# JMAP_SERVERS=[{"id":"eu","label":"Europe","url":"https://eu.example.com","domains":["example.com"]},{"id":"us","label":"US","url":"https://us.example.com","oauth":{"clientId":"webmail-us"}}]
# Pick the server automatically from the domain of the address the user types,
@@ -80,10 +81,19 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
# Signing out of an SSO account also signs out of the identity provider,
-# through the end_session_endpoint it advertises (RP-initiated logout). Set to
-# false when the provider is shared with apps that should stay signed in.
+# through the end_session_endpoint it advertises or OAUTH_END_SESSION_URL
+# (RP-initiated logout). Set to false when the provider is shared with apps
+# that should stay signed in.
# OAUTH_END_SESSION=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:
+# client_id, id_token_hint and post_logout_redirect_uri. Must be https.
+# Applies to OAUTH_ISSUER_URL's provider only; JMAP_SERVERS entries set their
+# own "endSessionUrl" in their "oauth" block.
+# OAUTH_END_SESSION_URL=https://your-idp.example.com/logout
+
# Where the identity provider sends the browser after signing the user out.
# Register it with the provider as a post-logout redirect URI first, or it
# will refuse the logout. Unset: the provider shows its own signed-out page.
diff --git a/app/(main)/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_tabs/_jmap-servers-section.tsx
index 2f0c531e0..3dab7f75d 100644
--- a/app/(main)/admin/_tabs/_jmap-servers-section.tsx
+++ b/app/(main)/admin/_tabs/_jmap-servers-section.tsx
@@ -20,6 +20,7 @@ interface RowDraft {
oauthIssuerUrl: string;
oauthClientSecret: string;
oauthButtonLabel: string;
+ oauthEndSessionUrl: string;
oauthExpanded: boolean;
}
@@ -33,7 +34,8 @@ function entryToDraft(e: JmapServerEntry): RowDraft {
oauthIssuerUrl: e.oauth?.issuerUrl ?? '',
oauthClientSecret: e.oauth?.clientSecret ?? '',
oauthButtonLabel: e.oauth?.buttonLabel ?? '',
- oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret)),
+ oauthEndSessionUrl: e.oauth?.endSessionUrl ?? '',
+ oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret || e.oauth.endSessionUrl)),
};
}
@@ -49,12 +51,14 @@ function draftToEntry(d: RowDraft): JmapServerEntry | null {
const issuerUrl = d.oauthIssuerUrl.trim().replace(/\/+$/, '');
const clientSecret = d.oauthClientSecret;
const buttonLabel = d.oauthButtonLabel.trim();
- const oauth = clientId || issuerUrl || clientSecret
+ const endSessionUrl = d.oauthEndSessionUrl.trim();
+ const oauth = clientId || issuerUrl || clientSecret || endSessionUrl
? {
...(clientId ? { clientId } : {}),
...(issuerUrl ? { issuerUrl } : {}),
...(clientSecret ? { clientSecret } : {}),
...(buttonLabel ? { buttonLabel } : {}),
+ ...(endSessionUrl ? { endSessionUrl } : {}),
}
: undefined;
return {
@@ -76,6 +80,7 @@ function emptyDraft(): RowDraft {
oauthIssuerUrl: '',
oauthClientSecret: '',
oauthButtonLabel: '',
+ oauthEndSessionUrl: '',
oauthExpanded: false,
};
}
@@ -268,6 +273,19 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
With a client ID, this label gives the server its own sign-in button on the login page, whichever server is selected and even while OAuth is off globally.
+ Where signing out sends the browser to end the provider's session, instead of the end_session_endpoint from discovery. Set it for providers that advertise none, such as Stalwart. Must be https.
+
+
)}
diff --git a/app/(main)/admin/_tabs/auth.tsx b/app/(main)/admin/_tabs/auth.tsx
index 7d28e969b..730111def 100644
--- a/app/(main)/admin/_tabs/auth.tsx
+++ b/app/(main)/admin/_tabs/auth.tsx
@@ -296,7 +296,8 @@ export function AuthTab() {
-
+
+
diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts
index 963179dc8..0631a5399 100644
--- a/app/api/auth/token/route.ts
+++ b/app/api/auth/token/route.ts
@@ -70,17 +70,26 @@ async function revokeRefreshToken(token: string, serverId: string | null, metada
}
}
-/** The provider logout URL for a slot being signed out, or null when there is none to visit. */
+/**
+ * The provider logout URL for a slot being signed out, or null when there is
+ * none to visit. A logout URL the admin configured (#1279) goes before the
+ * discovered one: it is there for providers that advertise none, such as
+ * Stalwart, and for a logout page shared with other apps, which must keep
+ * working once the provider starts advertising its own.
+ */
function endSessionUrlFor(metadata: OAuthMetadata | null, serverId: string | null, idToken: string | undefined): string | null {
- if (!metadata?.end_session_endpoint || !isEndSessionEnabled()) return null;
+ if (!isEndSessionEnabled()) return null;
let clientId: string;
+ let configuredEndpoint: string | undefined;
try {
- ({ clientId } = getRequiredConfig(serverId));
+ ({ clientId, endSessionUrl: configuredEndpoint } = getRequiredConfig(serverId));
} catch {
return null;
}
+ const endpoint = configuredEndpoint || metadata?.end_session_endpoint;
+ if (!endpoint) return null;
return buildEndSessionUrl({
- endpoint: metadata.end_session_endpoint,
+ endpoint,
clientId,
idToken,
postLogoutRedirectUri: getPostLogoutRedirectUri(),
@@ -265,7 +274,7 @@ export async function PUT(request: NextRequest) {
/**
* Revoke a slot's refresh token and clear its token cookies. With
* `endSession`, also returns the URL that ends the provider's session, or null
- * when the provider has no usable end_session_endpoint.
+ * when there is no usable logout URL.
*/
async function signOutSlot(
cookieStore: CookieStore,
diff --git a/lib/__tests__/oauth-issuer-precedence.test.ts b/lib/__tests__/oauth-issuer-precedence.test.ts
index 58d8ad9a9..5858d3bf3 100644
--- a/lib/__tests__/oauth-issuer-precedence.test.ts
+++ b/lib/__tests__/oauth-issuer-precedence.test.ts
@@ -25,6 +25,13 @@ const SERVERS = [
url: 'https://mx3.example.com',
oauth: { issuerUrl: 'https://idp3.example.com' },
},
+ {
+ id: 'mx4',
+ label: 'Server 4',
+ url: 'https://mx4.example.com',
+ oauth: { endSessionUrl: ' https://mx4.example.com/logout/ ' },
+ },
+ { id: 'mx5', label: 'Server 5', url: 'https://mx5.example.com', oauth: { endSessionUrl: 'logout' } },
];
vi.mock('@/lib/admin/config-manager', () => ({
@@ -64,3 +71,30 @@ describe('SSO discovery issuer precedence (#952)', () => {
expect(getRequiredConfig('unknown').discoveryUrl).toBe('https://idp1.example.com');
});
});
+
+describe('configured end-session URL precedence (#1279)', () => {
+ beforeEach(() => {
+ vi.stubEnv('JMAP_SERVER_URL', 'https://mx1.example.com');
+ vi.stubEnv('OAUTH_CLIENT_ID', 'client');
+ vi.stubEnv('OAUTH_END_SESSION_URL', 'https://idp1.example.com/logout');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('uses the selected server\'s own logout URL, as written', () => {
+ // A trailing slash is part of some providers' logout path.
+ expect(getRequiredConfig('mx4').endSessionUrl).toBe('https://mx4.example.com/logout/');
+ });
+
+ it('does not give a server entry the global logout URL', () => {
+ expect(getRequiredConfig('mx2').endSessionUrl).toBeUndefined();
+ expect(getRequiredConfig('mx5').endSessionUrl).toBeUndefined();
+ });
+
+ it('uses the global logout URL when no server entry matched', () => {
+ expect(getRequiredConfig(null).endSessionUrl).toBe('https://idp1.example.com/logout');
+ expect(getRequiredConfig('unknown').endSessionUrl).toBe('https://idp1.example.com/logout');
+ });
+});
diff --git a/lib/__tests__/oauth-token-route.test.ts b/lib/__tests__/oauth-token-route.test.ts
index b915acb54..d220afc01 100644
--- a/lib/__tests__/oauth-token-route.test.ts
+++ b/lib/__tests__/oauth-token-route.test.ts
@@ -611,6 +611,52 @@ describe('oauth token route - sign-out (#905)', () => {
expect(body.end_session_url).toBeUndefined();
});
+ // Stalwart's built-in provider advertises no end_session_endpoint (#1279).
+ it('builds the provider logout from the configured URL when discovery has none', async () => {
+ seedSlot(0);
+ getMetadata.mockResolvedValue({ ...KEYCLOAK, end_session_endpoint: undefined });
+ getRequiredConfig.mockReturnValue({ clientId: 'webmail', endSessionUrl: 'https://mail.example.org/logout/' });
+ configValues.oauthPostLogoutRedirectUri = 'https://mail.example.com/en/login';
+
+ const { body } = await callRoute('DELETE', { slot: '0', end_session: 'true' });
+
+ const url = new URL(body.end_session_url as string);
+ expect(`${url.origin}${url.pathname}`).toBe('https://mail.example.org/logout/');
+ expect(url.searchParams.get('client_id')).toBe('webmail');
+ expect(url.searchParams.get('id_token_hint')).toBe(ID_TOKEN);
+ expect(url.searchParams.get('post_logout_redirect_uri')).toBe('https://mail.example.com/en/login');
+ });
+
+ it('prefers the configured logout URL over the discovered one', async () => {
+ seedSlot(0);
+ getRequiredConfig.mockReturnValue({ clientId: 'webmail', endSessionUrl: 'https://sso.example.com/logout' });
+
+ const { body } = await callRoute('DELETE', { slot: '0', end_session: 'true' });
+
+ const url = new URL(body.end_session_url as string);
+ expect(`${url.origin}${url.pathname}`).toBe('https://sso.example.com/logout');
+ });
+
+ it('does not use the configured logout URL when the admin turned provider logout off', async () => {
+ seedSlot(0);
+ getRequiredConfig.mockReturnValue({ clientId: 'webmail', endSessionUrl: 'https://sso.example.com/logout' });
+ configValues.oauthEndSession = false;
+
+ const { body } = await callRoute('DELETE', { slot: '0', end_session: 'true' });
+
+ expect(body.end_session_url).toBeUndefined();
+ });
+
+ it('does not offer a configured logout URL over plain http', async () => {
+ seedSlot(0);
+ getMetadata.mockResolvedValue({ ...KEYCLOAK, end_session_endpoint: undefined });
+ getRequiredConfig.mockReturnValue({ clientId: 'webmail', endSessionUrl: 'http://mail.example.org/logout' });
+
+ const { body } = await callRoute('DELETE', { slot: '0', end_session: 'true' });
+
+ expect(body.end_session_url).toBeUndefined();
+ });
+
it.each([
['non-https', 'http://id.example.com/logout'],
['malformed', 'not a url'],
diff --git a/lib/admin/jmap-servers.ts b/lib/admin/jmap-servers.ts
index aa79b727a..d78f05d60 100644
--- a/lib/admin/jmap-servers.ts
+++ b/lib/admin/jmap-servers.ts
@@ -8,6 +8,8 @@ export interface JmapServerOAuthConfig {
clientSecret?: string;
/** Text of this server's sign-in button, e.g. "Sign in with Google"; the generic SSO label otherwise. */
buttonLabel?: string;
+ /** The provider's logout URL, used instead of a discovered end_session_endpoint (#1279). Never sent to the browser. */
+ endSessionUrl?: string;
}
export interface JmapServerEntry {
@@ -82,12 +84,15 @@ export function parseJmapServers(raw: unknown): JmapServerEntry[] {
const issuerUrl = typeof o.issuerUrl === 'string' ? trimUrl(o.issuerUrl) : '';
const clientSecret = typeof o.clientSecret === 'string' ? o.clientSecret : '';
const buttonLabel = typeof o.buttonLabel === 'string' ? o.buttonLabel.trim().slice(0, 64) : '';
- if (clientId || issuerUrl || clientSecret) {
+ // Kept as written: some providers' logout path ends in a slash.
+ const endSessionUrl = typeof o.endSessionUrl === 'string' ? o.endSessionUrl.trim() : '';
+ if (clientId || issuerUrl || clientSecret || endSessionUrl) {
oauth = {};
if (clientId) oauth.clientId = clientId;
if (issuerUrl && isHttpUrl(issuerUrl)) oauth.issuerUrl = issuerUrl;
if (clientSecret) oauth.clientSecret = clientSecret;
if (buttonLabel) oauth.buttonLabel = buttonLabel;
+ if (endSessionUrl && isHttpUrl(endSessionUrl)) oauth.endSessionUrl = endSessionUrl;
}
}
out.push({
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 8e17ce6f0..758f00125 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -266,6 +266,11 @@ export const CONFIG_ENV_MAP: Record('oauthEndSessionUrl', '') || process.env.OAUTH_END_SESSION_URL)?.trim() || undefined;
+ return { clientId, serverUrl, discoveryUrl, serverId: entry?.id, endSessionUrl };
}
export function getClientSecret(serverId?: string | null): string {