feat: mark unverified OAuth apps as not affiliated with Warmbly on the consent screen, reserve the Warmbly name in third-party app names (registration, edit, import, and self-registered clients), and show no website for self-registered clients

This commit is contained in:
Matthew Meszaros
2026-10-09 06:19:09 +02:00
parent 166637b395
commit db32f2347d
15 changed files with 227 additions and 59 deletions
+1
View File
@@ -163,6 +163,7 @@ Names are shown to other people, including in invitation and notification emails
- no links, web addresses or email addresses: nothing with `@`, `//`, `www.` or a scheme such as `https:`, no hostname such as `example.com`, and no IP address. Full-width and ideographic dots count as dots
- no control characters, invisible formatting characters (zero-width and bidirectional overrides), `<`, `>`, `` ` `` or `\`, and no more than three stacked combining marks
- leading and trailing spaces are trimmed and runs of spaces become one, and that is the form stored
- an OAuth app name cannot include Warmbly, spaced out or spelled with look-alike characters included. An app that already had such a name keeps it until the name is changed
```json
{
+1 -1
View File
@@ -36,7 +36,7 @@ Add the server with just its URL, and the client runs the OAuth flow on first us
```
<Callout type="info" title="What you are approving">
The consent screen shows exactly which scopes the client will get: the ones it asks for that your role in the workspace covers, so a connected client never does more than you could yourself. Sending real mail is never one of them: `SEND_CAMPAIGNS` and API-key management are excluded from OAuth-connected MCP clients by design, and the assistant only ever drafts. You can revoke a connected client at any time from your dashboard under connected apps.
The consent screen marks a self-registered client as **Not affiliated with Warmbly**, names where approving sends access, and shows exactly which scopes the client will get: the ones it asks for that your role in the workspace covers, so a connected client never does more than you could yourself. Sending real mail is never one of them: `SEND_CAMPAIGNS` and API-key management are excluded from OAuth-connected MCP clients by design, and the assistant only ever drafts. You can revoke a connected client at any time from your dashboard under connected apps.
</Callout>
## Connect with an API key
+4 -2
View File
@@ -11,7 +11,7 @@ Warmbly implements the OAuth 2.1 authorization-code flow with PKCE. Confidential
In the dashboard, open **Settings -> OAuth apps** and register an application. You provide:
- a **name**, optional description, logo, and website (shown on the consent screen),
- a **name**, optional description, logo, and website (shown on the consent screen). The name follows the [naming rules](/api/error-codes/#name-refusals) and cannot include Warmbly, spaced out or spelled with look-alike characters included, because people would take the app for Warmbly's own. An app that already had such a name keeps it until the name is changed,
- one or more **redirect URIs** (where users are sent back after they approve), matched exactly and required to be HTTPS, except loopback URLs for native apps,
- the **scopes** your app may request (any permission except `API_KEYS`, which stays with people and API keys),
- optionally, **allowed webhook domains** (see below).
@@ -67,6 +67,8 @@ curl -X POST "https://api.warmbly.com/v1/oauth/register" \
The registration endpoint is open (unauthenticated) and rate-limited per source IP. Registering grants no access on its own: a human still signs in and approves scopes at the consent screen. Redirect URIs may be HTTPS, loopback (`http://127.0.0.1` or `localhost`, any port), or a private-use scheme (`myapp://`), matched exactly at authorize and token time. Self-registered clients can never request `SEND_CAMPAIGNS` or `API_KEYS`.
Nobody vouches for a self-registered client, so the consent screen shows only what Warmbly can stand behind. `client_uri` and `logo_uri` are ignored, so the screen shows no website or logo for it. Its `client_name` is cleaned rather than refused: a bracketed part that names Warmbly is dropped (`Claude Code (warmbly)` is shown as `Claude Code`), and a name that still names Warmbly, or breaks the naming rules, is shown as `MCP client`.
For a public client the token exchange sends no `client_secret`, only the PKCE `code_verifier`. Everything else in the flow below is identical. If you are connecting an AI assistant, you do not run any of this by hand: see the [MCP server](/api/mcp/) page for the one-command setup.
## Scopes
@@ -106,7 +108,7 @@ https://app.warmbly.com/oauth/authorize
### 2. The user approves
Warmbly shows the app, the workspace that will receive access, the scopes it will get, and an authorize or deny choice. An app is marked **Unverified app** unless it is a registered (not self-registered) app the instance features in its [community directory](#publish-to-the-community-directory). Approving asks the member to confirm it is them when they have not signed in or re-authenticated in the last few minutes, because the approval hands your app a standing credential. On approval the browser is redirected back to your `redirect_uri` with a single-use code:
Warmbly shows the app, the workspace that will receive access, the scopes it will get, where approving sends access (the redirect URI's host, or an app on this device for a loopback or private-use redirect), and an authorize or deny choice. An app is marked **Unverified app** unless it is a registered (not self-registered) app the instance features in its [community directory](#publish-to-the-community-directory), and an unverified app also carries a **Not affiliated with Warmbly** notice that asks the member to approve only a connection they started themselves. Approving asks the member to confirm it is them when they have not signed in or re-authenticated in the last few minutes, because the approval hands your app a standing credential. On approval the browser is redirected back to your `redirect_uri` with a single-use code:
```
https://yourapp.com/oauth/callback?code=wmac_...&state=<your-state>
+3 -20
View File
@@ -8,7 +8,6 @@ import (
"time"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/displayname"
)
// Dynamic Client Registration (RFC 7591). An MCP client (Claude Code, Cursor,
@@ -23,7 +22,8 @@ import (
const dcrMaxPerIPPerHour = 20
// DCRRequest is the RFC 7591 client-registration request (only the members we
// honor; unknown members are ignored per the spec).
// honor; unknown members are ignored per the spec). client_uri and logo_uri are
// not honored: nobody vouches for a self-registered client's website or logo.
type DCRRequest struct {
ClientName string `json:"client_name"`
RedirectURIs []string `json:"redirect_uris"`
@@ -31,8 +31,6 @@ type DCRRequest struct {
ResponseTypes []string `json:"response_types"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
Scope string `json:"scope"`
ClientURI string `json:"client_uri"`
LogoURI string `json:"logo_uri"`
}
// DCRResponse is the RFC 7591 client-information response. client_secret is set
@@ -65,10 +63,7 @@ func (s *Service) RegisterDynamicClient(ctx context.Context, clientIP string, re
}
// A self-registered name nobody can be asked to correct is cleaned, not refused.
name := displayname.Clean(req.ClientName, displayname.Workspace)
if name == "" {
name = "MCP client"
}
name := dcrClientName(req.ClientName)
uris, err := validateDCRRedirectURIs(req.RedirectURIs)
if err != nil {
@@ -104,7 +99,6 @@ func (s *Service) RegisterDynamicClient(ctx context.Context, clientIP string, re
app := &models.OAuthApplication{
Name: name,
WebsiteURL: dcrWebsite(req.ClientURI),
ClientID: clientID,
RedirectURIs: uris,
AllowedWebhookDomains: []string{},
@@ -222,14 +216,3 @@ func (s *Service) checkDCRRate(ctx context.Context, ip string) error {
}
return nil
}
// dcrWebsite keeps a self-registered client's website only when it is a plain
// http(s) address. Its logo_uri is never kept: the consent screen shows only
// images this instance stored.
func dcrWebsite(raw string) string {
w, err := appWebsite(raw)
if err != nil {
return ""
}
return w
}
+11 -5
View File
@@ -42,6 +42,8 @@ type ConsentInfo struct {
OrganizationName string `json:"organization_name"`
// Verified is a registered app the instance's operators feature in the directory.
Verified bool `json:"verified"`
// SelfRegistered is a client that registered itself (RFC 7591), so nobody vouches for its name.
SelfRegistered bool `json:"self_registered"`
}
// TokenResponse is the /token success body (RFC 6749 §5.1).
@@ -86,15 +88,18 @@ func (s *Service) AuthorizeDetails(ctx context.Context, roleCap uint64, req Auth
if werr != nil {
website = ""
}
name := app.Name
verified := false
if !app.DynamicallyRegistered {
if featured, ferr := s.repo.IsFeatured(ctx, app.ID); ferr == nil {
verified = featured
}
if app.DynamicallyRegistered {
// Rows stored before these rules are held to them on the way out too.
name = dcrClientName(app.Name)
website = ""
} else if featured, ferr := s.repo.IsFeatured(ctx, app.ID); ferr == nil {
verified = featured
}
return &ConsentInfo{
ClientID: app.ClientID,
Name: app.Name,
Name: name,
Description: app.Description,
LogoURL: app.LogoURL,
WebsiteURL: website,
@@ -103,6 +108,7 @@ func (s *Service) AuthorizeDetails(ctx context.Context, roleCap uint64, req Auth
WithheldScopes: ScopeList(withheld),
State: req.State,
Verified: verified,
SelfRegistered: app.DynamicallyRegistered,
}, nil
}
+5 -1
View File
@@ -41,7 +41,11 @@ func IssuedLogoKey(pu storage.PublicURLer, orgID uuid.UUID, logoURL string) (str
// ImportedName is the archive's app name under the naming rules, or a neutral one.
func ImportedName(raw string) string {
return displayname.CleanOr(raw, displayname.Workspace, "Imported app")
name := displayname.CleanOr(raw, displayname.Workspace, "Imported app")
if mentionsPlatform(name) {
return "Imported app"
}
return name
}
// ImportedWebsite is the archive's website when it is an http(s) address.
+79
View File
@@ -0,0 +1,79 @@
package oauth
import (
"regexp"
"strings"
"golang.org/x/text/unicode/norm"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/pkg/displayname"
)
// platformName is the name no third-party app may present itself under.
const platformName = "warmbly"
// dcrFallbackName stands in for a self-registered client with no usable name.
const dcrFallbackName = "MCP client"
var (
// confusables folds look-alike characters onto plain letters, after NFKC and lowercasing.
confusables = strings.NewReplacer(
"0", "o", "1", "l", "|", "l", "!", "l", "i", "l", "ı", "l", "4", "a", "@", "a",
"а", "a", "α", "a", "ɑ", "a", "у", "y", "ʏ", "y", "ԝ", "w", "ᴡ", "w",
"ʀ", "r", "ᴍ", "m", "ʙ", "b", "ʟ", "l",
)
digraphs = strings.NewReplacer("rn", "m", "vv", "w")
// A bracketed aside, as MCP clients append the server's local label: "Claude Code (warmbly)".
asideRe = regexp.MustCompile(`\s*[(\[][^()\[\]]*[)\]]`)
)
// mentionsPlatform reports whether name spells the platform's name, through
// spacing, punctuation and look-alike characters.
func mentionsPlatform(name string) bool {
folded := confusables.Replace(strings.ToLower(norm.NFKC.String(name)))
var b strings.Builder
for _, r := range folded {
if r >= 'a' && r <= 'z' {
b.WriteRune(r)
}
}
return strings.Contains(digraphs.Replace(b.String()), platformName)
}
func errPlatformName() error {
return errx.NewWithIdentifier(errx.BadRequest, displayname.ErrorCode,
"name cannot include Warmbly: people connecting the app would take it for Warmbly's own.")
}
// appName applies the shared naming rules plus the platform-name rule. A name
// left as it was (current) keeps passing, so an existing app stays editable.
// An app name is shown to every workspace that sees its consent screen or its directory listing.
func appName(raw, current string) (string, error) {
name, xerr := displayname.Validate("name", raw, displayname.Workspace, false)
if xerr != nil {
return "", xerr
}
if name != current && mentionsPlatform(name) {
return "", errPlatformName()
}
return name, nil
}
// dcrClientName is a self-registered client's name with any aside naming the
// platform dropped, or a neutral name when what remains still names it.
func dcrClientName(raw string) string {
name := displayname.Clean(raw, displayname.Workspace)
if mentionsPlatform(name) {
name = displayname.Clean(asideRe.ReplaceAllStringFunc(name, func(aside string) string {
if mentionsPlatform(aside) {
return ""
}
return aside
}), displayname.Workspace)
}
if name == "" || mentionsPlatform(name) {
return dcrFallbackName
}
return name
}
+41 -4
View File
@@ -3,11 +3,11 @@ package oauth
import "testing"
func TestAppNameFollowsTheNamingRules(t *testing.T) {
if got, err := appName(" Acme Sync "); err != nil || got != "Acme Sync" {
if got, err := appName(" Acme Sync ", ""); err != nil || got != "Acme Sync" {
t.Fatalf("appName = %q, %v", got, err)
}
for _, bad := range []string{"", "Verify at evil.example", "https://acme.com", "Acme ‮ppa", "<b>Acme</b>"} {
if _, err := appName(bad); err == nil {
if _, err := appName(bad, ""); err == nil {
t.Errorf("appName(%q) accepted", bad)
}
}
@@ -24,7 +24,44 @@ func TestAppWebsite(t *testing.T) {
t.Errorf("appWebsite(%q) accepted", bad)
}
}
if dcrWebsite("javascript:alert(1)") != "" {
t.Fatal("a self-registered client kept an unsafe website")
}
func TestAppNameRefusesThePlatformName(t *testing.T) {
for _, bad := range []string{"Warmbly", "Warmbly Support", "HubSpot for warmbly", "W a r m b l y", "Warm-bly", "WarmbIy", "Warrnbly", "VVarmbly", "Wаrmbly", "Warmbly"} {
if _, err := appName(bad, ""); err == nil {
t.Errorf("appName(%q) accepted", bad)
}
}
for _, ok := range []string{"Acme Sync", "Warm Leads", "Swarm"} {
if _, err := appName(ok, ""); err != nil {
t.Errorf("appName(%q) refused: %v", ok, err)
}
}
if _, err := appName("Warmbly Sync", "Warmbly Sync"); err != nil {
t.Errorf("an unchanged existing name was refused: %v", err)
}
if _, err := appName("Warmbly Sync 2", "Warmbly Sync"); err == nil {
t.Error("a changed name naming the platform was accepted")
}
}
func TestDCRClientNameDropsThePlatformName(t *testing.T) {
cases := map[string]string{
"Claude Code (warmbly)": "Claude Code",
"Cursor [Warmbly prod]": "Cursor",
"Cursor": "Cursor",
"Claude Code (work)": "Claude Code (work)",
"Warmbly Support": dcrFallbackName,
"Warmbly (Claude Code)": dcrFallbackName,
"": dcrFallbackName,
"https://warmbly.example": dcrFallbackName,
}
for in, want := range cases {
if got := dcrClientName(in); got != want {
t.Errorf("dcrClientName(%q) = %q, want %q", in, got, want)
}
}
if got := ImportedName("Warmbly Sync"); got != "Imported app" {
t.Errorf("ImportedName kept the platform name: %q", got)
}
}
+2 -13
View File
@@ -23,7 +23,6 @@ import (
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/cache"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/displayname"
"github.com/warmbly/warmbly/internal/pkg/whdomain"
"github.com/warmbly/warmbly/internal/repository"
)
@@ -68,16 +67,6 @@ func invalidf(format string, args ...any) error {
return &ValidationError{msg: fmt.Sprintf(format, args...)}
}
// appName applies the shared naming rules: an app name is shown to every
// workspace that sees its consent screen or its directory listing.
func appName(raw string) (string, error) {
name, xerr := displayname.Validate("name", raw, displayname.Workspace, false)
if xerr != nil {
return "", xerr
}
return name, nil
}
// appWebsite accepts an empty value or an http(s) address on a host, without
// credentials; it is shown to other workspaces as a link.
func appWebsite(raw string) (string, error) {
@@ -151,7 +140,7 @@ func (s *Service) RegisterApplication(ctx context.Context, orgID, userID uuid.UU
if err := s.CheckDeveloperAccess(ctx, orgID, userID); err != nil {
return nil, err
}
name, err := appName(w.Name)
name, err := appName(w.Name, "")
if err != nil {
return nil, err
}
@@ -226,7 +215,7 @@ func (s *Service) UpdateApplication(ctx context.Context, orgID, userID, id uuid.
if app.SuspendedAt != nil {
return nil, ErrAppSuspended
}
name, err := appName(w.Name)
name, err := appName(w.Name, app.Name)
if err != nil {
return nil, err
}
@@ -15,7 +15,7 @@ import { CheckSquare } from "@/components/ui/check-square";
import { Label, TextInput } from "@/components/ui/field";
import { useConfirm } from "@/hooks/context/confirm";
import useAPIPermissions from "@/lib/api/hooks/app/api-keys/useAPIPermissions";
import { nameError } from "@/lib/displayName";
import { appNameError } from "@/lib/displayName";
import { useCreateOAuthApp, useSetOAuthAppLogo } from "@/lib/api/hooks/app/oauth/useOAuthApps";
import { useWebhookEventCatalog } from "@/lib/api/hooks/app/webhooks/useWebhooks";
import type APIPermission from "@/lib/api/models/app/apikeys/APIPermission";
@@ -90,7 +90,7 @@ export default function RegisterAppDialog({ onClose }: { onClose: () => void })
const redirectList = redirects.map((r) => r.trim()).filter(Boolean);
const redirectErrors = redirects.map(redirectProblem);
const nameProblem = nameError("Name", name, "workspace");
const nameProblem = appNameError("Name", name);
const websiteProblem = website.trim() && !/^https?:\/\/[^\s/]+/i.test(website.trim()) ? "Start with https://" : null;
const webhookProblem =
webhooksOn && webhookUrl.trim() && !/^https:\/\/[^\s/]+/i.test(webhookUrl.trim()) ? "Use an https address" : null;
+2 -2
View File
@@ -27,7 +27,7 @@ import { usePermission } from "@/hooks/usePermission";
import { EmptyBlock } from "@/components/layout/Page";
import { Label, TextInput } from "@/components/ui/field";
import { cn } from "@/lib/utils";
import { nameError } from "@/lib/displayName";
import { appNameError } from "@/lib/displayName";
import { useConfirm } from "@/hooks/context/confirm";
import useAPIPermissions from "@/lib/api/hooks/app/api-keys/useAPIPermissions";
import type APIPermission from "@/lib/api/models/app/apikeys/APIPermission";
@@ -607,7 +607,7 @@ function EditModal({ app, onClose }: { app: OAuthApplication; onClose: () => voi
const webhookUrlValid = webhookUrl.trim() === "" || /^https:\/\/.+/i.test(webhookUrl.trim());
const save = async () => {
const nameProblem = nameError("Name", name, "workspace");
const nameProblem = appNameError("Name", name, app.name);
if (nameProblem) {
toast.error(nameProblem);
return;
+32 -8
View File
@@ -40,6 +40,21 @@ function isSafeRedirect(rawUrl: string): boolean {
}
}
// Where approving sends the code, phrased for a person: a loopback address is a
// program on this device, a private-use scheme names the app that claims it.
function redirectDestination(rawUrl: string): string {
try {
const u = new URL(rawUrl);
if (u.protocol === "http:" || u.protocol === "https:") {
const loopback = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
return loopback ? "an app on this device" : u.host;
}
return `the ${u.protocol.replace(/:$/, "")} app on this device`;
} catch {
return "the app";
}
}
export default function OAuthConsentPage() {
const [sp] = useSearchParams();
const params = React.useMemo(() => Object.fromEntries(sp.entries()), [sp]);
@@ -142,7 +157,6 @@ export default function OAuthConsentPage() {
<h1 className="text-[15px] font-semibold text-slate-900">{info.name}</h1>
{!info.verified && (
<span
title="Warmbly has not reviewed this app. Only continue if you trust who made it."
className="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10.5px] font-medium text-amber-700 ring-1 ring-amber-200"
>
<ShieldAlertIcon className="h-3 w-3" />
@@ -166,6 +180,21 @@ export default function OAuthConsentPage() {
)}
</div>
{!info.verified && (
<div className="mx-6 mt-5 flex gap-2.5 rounded-lg bg-amber-50 px-3 py-2.5 ring-1 ring-amber-200">
<ShieldAlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div className="text-[12px] leading-relaxed text-amber-800">
<p className="font-medium text-amber-900">Not affiliated with Warmbly</p>
<p>
{info.self_registered
? "This app registered itself and chose its own name, so Warmbly cannot say who made it."
: "Someone outside Warmbly made this app, and Warmbly has not reviewed it."}{" "}
Approve only if you started this connection yourself.
</p>
</div>
</div>
)}
<div className="px-6 py-5">
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 mb-2">It will be able to</div>
<ul className="space-y-1.5">
@@ -211,13 +240,8 @@ export default function OAuthConsentPage() {
</button>
</div>
<p className="px-6 pb-5 text-center text-[11px] text-slate-400 leading-relaxed">
You'll be redirected to {(() => {
try {
return new URL(info.redirect_uri).host;
} catch {
return "the app";
}
})()}. Only authorize apps you trust.
Authorizing sends access to <span className="font-medium text-slate-600">{redirectDestination(info.redirect_uri)}</span>. Only
authorize apps you trust.
</p>
</div>
);
@@ -71,6 +71,8 @@ export interface OAuthConsentInfo {
organization_name: string;
// A registered app the instance features in its directory.
verified: boolean;
// Registered itself (RFC 7591), so nobody vouches for its name.
self_registered: boolean;
}
// An app the current user has authorized (GET /oauth/authorized-apps).
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { nameError } from "./displayName";
import { appNameError, nameError } from "./displayName";
describe("nameError", () => {
it.each(["Ada", " Mary Ann ", "O'Brien-Smith", "J.R.R. Tolkien", "St. John", "Zoë", "李小龙"])(
@@ -39,3 +39,17 @@ describe("nameError", () => {
expect(nameError("Name", "3000", "person")).toMatch(/letter/);
});
});
describe("appNameError", () => {
it.each(["Warmbly", "Warmbly Support", "HubSpot for warmbly", "W a r m b l y", "WarmbIy", "Warrnbly", "VVarmbly", "Wаrmbly", "Warmbly"])(
"refuses %s",
(v) => expect(appNameError("Name", v)).toMatch(/cannot include Warmbly/),
);
it.each(["Acme Sync", "Warm Leads", "Swarm"])("accepts %s", (v) => expect(appNameError("Name", v)).toBeNull());
it("keeps an existing name editable", () => {
expect(appNameError("Name", "Warmbly Sync", "Warmbly Sync")).toBeNull();
expect(appNameError("Name", "Warmbly Sync 2", "Warmbly Sync")).toMatch(/cannot include Warmbly/);
});
});
+27
View File
@@ -45,3 +45,30 @@ export function nameError(label: string, value: string, kind: NameKind, optional
}
return null;
}
// Mirrors mentionsPlatform in internal/app/oauth/naming.go.
const CONFUSABLES: Record<string, string> = {
"0": "o", "1": "l", "|": "l", "!": "l", i: "l", "ı": "l", "4": "a", "@": "a",
"а": "a", "α": "a", "ɑ": "a", "у": "y", "ʏ": "y", "ԝ": "w", "ᴡ": "w",
"ʀ": "r", "ᴍ": "m", "ʙ": "b", "ʟ": "l",
};
function mentionsPlatform(value: string): boolean {
const folded = [...value.normalize("NFKC").toLowerCase()]
.map((ch) => CONFUSABLES[ch] ?? ch)
.join("")
.replace(/[^a-z]/g, "")
.replace(/rn|vv/g, (d) => (d === "rn" ? "m" : "w"));
return folded.includes("warmbly");
}
/** nameError for an OAuth app, which also may not name Warmbly unless it already did (current). */
export function appNameError(label: string, value: string, current = ""): string | null {
const problem = nameError(label, value, "workspace");
if (problem) return problem;
const name = normalizeName(value);
if (name !== current && mentionsPlatform(name)) {
return `${label} cannot include Warmbly: people connecting the app would take it for Warmbly's own.`;
}
return null;
}