mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-19 16:03:14 +00:00
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:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user