mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
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:
+3
-1
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user