From 5597eadc28e6dda7c0ce79de072323180e8a13c4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:01:17 +0200 Subject: [PATCH] fix: give a server its own sign-in button only when it has a button label #1135 A server entry's OAuth client ID already has a meaning while OAuth is off globally: it is the registered client that password and TOTP logins use with Stalwart's require_client_registration. The PR made every such entry offer OAuth on its own, so existing installs would suddenly run discovery and show SSO buttons on the login page, and multi-IdP setups would get one extra button per server. A server now gets its own button only with both a client ID and a button label, which no existing config has. The rule lives in `offersOwnOAuth` with a test. Also: - the OAuth-only login form uses the selected server's label - the public config validator checks `buttonLabel` is a string - the admin hint and `.env.example` say the label enables the button --- .env.example | 4 +++- app/(main)/[locale]/login/page.tsx | 15 ++++++++------- app/(main)/admin/_tabs/_jmap-servers-section.tsx | 2 +- .../__tests__/jmap-servers-oauth-label.test.ts | 14 +++++++++++++- lib/admin/jmap-servers.ts | 10 ++++++++++ lib/config-validation.ts | 3 ++- 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index d26a4cc88..fac2b79bf 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,9 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Offer several JMAP servers on the login form. JSON array; each entry needs # id, label, and url. "domains" and a per-server "oauth" block are optional. # Prefer configuring this from the admin dashboard - the env form exists for -# stateless deployments. +# 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. # 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, diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index f330f2ef5..62ec0967a 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -19,7 +19,7 @@ import { type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { DEFAULT_OAUTH_SCOPES } from "@/lib/oauth/scopes"; import { useUpdateStore, selectBanner } from "@/stores/update-store"; -import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers"; +import { offersOwnOAuth, type PublicJmapServerEntry } from "@/lib/admin/jmap-servers"; import { IS_LITE, getLiteInjectedClientId } from "@/lib/lite"; import { getLiteClientId, probeLiteTokenLogin } from "@/lib/auth/lite-tokens"; import { @@ -188,9 +188,10 @@ function LoginPageContent() { const effectiveOauthIssuerUrl = selectedServer ? selectedServer.oauth?.issuerUrl || selectedServer.url : globalOauthIssuerUrl; - // A server entry that names its own OAuth client signs in with OAuth even - // while OAuth is off globally: a Gmail bridge next to a password server. - const serverOauthEnabled = oauthEnabled || !!selectedServer?.oauth?.clientId; + // A server entry that names its own OAuth client and button label signs in + // with OAuth even while OAuth is off globally: a Gmail bridge next to a + // password server. + const serverOauthEnabled = oauthEnabled || offersOwnOAuth(selectedServer); const [totpCode, setTotpCode] = useState(""); const [showTotpField, setShowTotpField] = useState(false); // Access-token sign-in (Bearer auth) in place of username and password. @@ -929,7 +930,7 @@ function LoginPageContent() { // server-side against the selected server. const otherOauthServers = isMobileHandoff ? [] - : jmapServers.filter((s) => s.oauth?.clientId && s.id !== selectedServer?.id); + : jmapServers.filter((s) => offersOwnOAuth(s) && s.id !== selectedServer?.id); const otherOauthButtons = otherOauthServers.map((s) => ( )); @@ -1271,7 +1272,7 @@ function LoginPageContent() { ) : (
- {t("sign_in_sso")} + {selectedServer?.oauth?.buttonLabel || t("sign_in_sso")}
)} diff --git a/app/(main)/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_tabs/_jmap-servers-section.tsx index 252da332c..2f0c531e0 100644 --- a/app/(main)/admin/_tabs/_jmap-servers-section.tsx +++ b/app/(main)/admin/_tabs/_jmap-servers-section.tsx @@ -265,7 +265,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props) 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" />

- A server with its own client ID offers OAuth sign-in even while OAuth is off globally. + 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.

diff --git a/lib/admin/__tests__/jmap-servers-oauth-label.test.ts b/lib/admin/__tests__/jmap-servers-oauth-label.test.ts index eb6aa4fee..166928602 100644 --- a/lib/admin/__tests__/jmap-servers-oauth-label.test.ts +++ b/lib/admin/__tests__/jmap-servers-oauth-label.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers'; +import { offersOwnOAuth, parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers'; describe('jmap server OAuth button label', () => { it('keeps a trimmed, bounded label with the OAuth settings and exposes it without the secret', () => { @@ -21,4 +21,16 @@ describe('jmap server OAuth button label', () => { const pub = redactJmapServers(parsed); expect(pub[0]!.oauth).toEqual({ clientId: 'bulwark', buttonLabel: 'Sign in with Google' }); }); + + it('offers a button of its own only for a client id with a label', () => { + const server = (oauth?: { clientId?: string; buttonLabel?: string }) => + ({ id: 's', label: 'S', url: 'https://mail.example.com', domains: [], ...(oauth ? { oauth } : {}) }); + expect(offersOwnOAuth(server({ clientId: 'bridge', buttonLabel: 'Sign in with Google' }))).toBe(true); + // A client id alone is the registered client for password and TOTP logins + // (Stalwart's require_client_registration), not an OAuth sign-in. + expect(offersOwnOAuth(server({ clientId: 'webmail' }))).toBe(false); + expect(offersOwnOAuth(server({ buttonLabel: 'Sign in' }))).toBe(false); + expect(offersOwnOAuth(server())).toBe(false); + expect(offersOwnOAuth(undefined)).toBe(false); + }); }); diff --git a/lib/admin/jmap-servers.ts b/lib/admin/jmap-servers.ts index 99518fe02..c1d54e777 100644 --- a/lib/admin/jmap-servers.ts +++ b/lib/admin/jmap-servers.ts @@ -103,6 +103,16 @@ export function parseJmapServers(raw: unknown): JmapServerEntry[] { return out; } +/** + * An entry with its own OAuth client and a button label gets a sign-in button + * of its own on the login page, even while OAuth is off globally. A client id + * alone keeps its older meaning: the registered client that password and TOTP + * logins use, which must not start offering OAuth on its own. + */ +export function offersOwnOAuth(server: PublicJmapServerEntry | undefined): boolean { + return !!(server?.oauth?.clientId && server.oauth.buttonLabel); +} + /** Strip secrets for client-side exposure. */ export function redactJmapServers(servers: JmapServerEntry[]): PublicJmapServerEntry[] { return servers.map((s) => ({ diff --git a/lib/config-validation.ts b/lib/config-validation.ts index 2ebeff5c6..8a9a9a5e8 100644 --- a/lib/config-validation.ts +++ b/lib/config-validation.ts @@ -19,7 +19,8 @@ function isJmapServers(value: unknown): value is PublicJmapServerEntry[] { if (entry.oauth === undefined) return true; return isRecord(entry.oauth) && (entry.oauth.clientId === undefined || isString(entry.oauth.clientId)) - && (entry.oauth.issuerUrl === undefined || isString(entry.oauth.issuerUrl)); + && (entry.oauth.issuerUrl === undefined || isString(entry.oauth.issuerUrl)) + && (entry.oauth.buttonLabel === undefined || isString(entry.oauth.buttonLabel)); }); }