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
This commit is contained in:
Linus Rath
2026-10-01 11:01:17 +02:00
parent d2855695a6
commit 5597eadc28
6 changed files with 37 additions and 11 deletions
+3 -1
View File
@@ -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,
+8 -7
View File
@@ -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) => (
<Button
key={s.id}
@@ -940,7 +941,7 @@ function LoginPageContent() {
disabled={oauthLoading || isLoading}
>
<LogIn className="w-4 h-4 me-2" />
{s.oauth?.buttonLabel || `${t("sign_in_sso")} (${s.label})`}
{s.oauth?.buttonLabel}
</Button>
));
@@ -1271,7 +1272,7 @@ function LoginPageContent() {
) : (
<div className="flex items-center gap-2">
<LogIn className="w-4 h-4" />
{t("sign_in_sso")}
{selectedServer?.oauth?.buttonLabel || t("sign_in_sso")}
</div>
)}
</Button>
@@ -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"
/>
<p className="mt-1 text-[11px] text-muted-foreground">
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.
</p>
</div>
</div>
@@ -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);
});
});
+10
View File
@@ -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) => ({
+2 -1
View File
@@ -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));
});
}