feat: end the provider session through a configured logout URL #1279

This commit is contained in:
Linus Rath
2026-10-18 13:46:48 +02:00
parent 89001ededa
commit 4adbad25f6
10 changed files with 150 additions and 15 deletions
+13 -3
View File
@@ -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.
@@ -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.
</p>
</div>
<div className="sm:col-span-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Provider logout URL</label>
<input
type="url"
value={d.oauthEndSessionUrl}
onChange={(e) => update(i, { oauthEndSessionUrl: e.target.value })}
placeholder="https://auth.example.com/logout"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="mt-1 text-[11px] text-muted-foreground">
Where signing out sends the browser to end the provider&apos;s session, instead of the end_session_endpoint from discovery. Set it for providers that advertise none, such as Stalwart. Must be https.
</p>
</div>
</div>
)}
</div>
+2 -1
View File
@@ -296,7 +296,8 @@ export function AuthTab() {
<Section title="Single Sign-On">
<Toggle 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} />
<Toggle 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. 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} />
<Toggle 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} />
<Text 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" />
<Text 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" />
</Section>
+14 -5
View File
@@ -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,
@@ -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');
});
});
+46
View File
@@ -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'],
+6 -1
View File
@@ -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({
+5
View File
@@ -266,6 +266,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 },
// 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
// oauth.endSessionUrl.
oauthEndSessionUrl: { envVar: 'OAUTH_END_SESSION_URL', type: 'url', defaultValue: '' },
// Where the provider sends the browser after ending its session. Must be
// registered with the provider as a post-logout redirect URI. Empty = none
// is sent and the provider shows its own signed-out page.
+4 -2
View File
@@ -11,7 +11,9 @@ import { fitsSetCookieBudget } from '@/lib/oauth/set-cookie-budget';
* "Sign in with SSO" silently signs the same person back in. Signing out
* therefore finishes with a top-level navigation to the provider's
* end_session_endpoint, carrying the parameters it needs to identify the
* session and the client.
* session and the client. Providers that advertise none get a logout URL
* from the admin instead (OAUTH_END_SESSION_URL or a server's
* oauth.endSessionUrl, #1279), and it is sent the same parameters.
*/
export const ID_TOKEN_COOKIE = 'jmap_idt';
@@ -106,7 +108,7 @@ export function getPostLogoutRedirectUri(): string | undefined {
/**
* The URL to navigate the browser to so the provider ends its session, or
* null when the discovered endpoint is unusable.
* null when the discovered or configured endpoint is unusable.
*
* `client_id` is always sent: providers use it to validate the redirect URI
* and to find the session when no id token is at hand. `id_token_hint` is sent
+6 -1
View File
@@ -118,7 +118,12 @@ export function getRequiredConfig(serverId?: string | null, options?: ClientConf
if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) {
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
}
return { clientId, serverUrl, discoveryUrl, serverId: entry?.id };
// Belongs to the same provider as the issuer, so the same rule applies:
// the global OAUTH_END_SESSION_URL only without a server entry (#1279).
const endSessionUrl = entry
? entry.oauth?.endSessionUrl
: (configManager.get<string>('oauthEndSessionUrl', '') || process.env.OAUTH_END_SESSION_URL)?.trim() || undefined;
return { clientId, serverUrl, discoveryUrl, serverId: entry?.id, endSessionUrl };
}
export function getClientSecret(serverId?: string | null): string {