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.

+
+ + 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" + /> +

+ 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 {