mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: end the provider session through a configured logout URL #1279
This commit is contained in:
+13
-3
@@ -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'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user