mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 03:45:06 +03:00
## What kind of change does this PR introduce? Feature + docs. Stacked on #48161 (logo contract / [DEPR-604](https://linear.app/supabase/issue/DEPR-604/define-connect-logo-asset-and-variant-contract)). ## What is the current behavior? After #48161, curated logos only resolve from allowlisted `redirect_uri` hosts. A requester can still present a trusted partner **name** (e.g. Claude) while redirecting to an unrelated remote host; the UI shows Supabase alone but does not call out the mismatch. ## What is the new behavior? - Shows a caution admonition when the requester name looks like a trusted partner (Claude, Cursor, ChatGPT/OpenAI, Perplexity) but `redirect_uri` is a **remote** host outside that partner's allowlist. - Skips localhost / loopback redirects for the caution (common for local MCP clients); those still get curated logos when the name matches a trusted partner. - Highlights the footer redirect URL in warning colour when the caution is shown. - Documents the behaviour in the Connect interstitials pattern. ### To test Real MCP clients (Claude, Cursor, etc.) only send users to **production** `/authorize`, so you cannot drive a local or preview Studio build from those tools. Use a Network override instead: 1. Start Studio and sign in (`pnpm dev:studio`, or use the [Vercel preview](https://studio-staging-git-danny-oauth-impersonation-warning-supabase.vercel.app/)). 2. Open `/dashboard/authorize?auth_id=foo` (any `auth_id` is fine; the real response may 404) ([Vercel preview](https://studio-staging-git-danny-oauth-impersonation-warning-supabase.vercel.app/dashboard/authorize?auth_id=foo)). 3. DevTools → **Network** → find `GET …/platform/oauth/authorizations/foo` (or whatever id you used). 4. Right-click → **Override content** (enable Local Overrides / pick a folder if prompted). 5. Paste one of the payloads below (status **200**), save, then reload the authorize page. 6. Keep `expires_at` in the future so the request does not look expired. #### Impersonation caution (trusted name + remote non-allowlisted redirect) Expect: - Supabase alone (no curated Claude mark) - Caution: “Redirect does not match this app name” - Footer redirect URL in warning colour ```json { "name": "Claude", "website": "https://claude.ai", "icon": null, "domain": "claude.ai", "redirect_uri": "https://evil.com/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` | Preview | | --- | | <img width="764" height="958" alt="Authorize Claude Supabase" src="https://github.com/user-attachments/assets/e6eee016-5710-41ba-9925-87511e009e22" /> | #### Localhost MCP: no caution Expect curated Claude + Supabase pair (name match + loopback), **no** caution, normal footer colour. Local MCP clients often use loopback redirects. ```json { "name": "Claude", "website": "https://claude.ai", "icon": null, "domain": "claude.ai", "redirect_uri": "http://127.0.0.1:42813/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` | Preview | | --- | | <img width="764" height="958" alt="Authorize Claude Supabase" src="https://github.com/user-attachments/assets/79f36865-3c8e-43e5-9490-24288efc74aa" /> | #### Legitimate curated partner: no caution Expect curated Cursor + Supabase pair, no admonition, normal footer colour. ```json { "name": "Cursor", "website": "https://cursor.com", "icon": null, "domain": "cursor.com", "redirect_uri": "https://cursor.com/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` | Preview | | --- | | <img width="764" height="958" alt="56164" src="https://github.com/user-attachments/assets/412333a3-a74f-42eb-9f63-d56b6a26bf91" /> | #### Unrelated name + remote redirect: no caution Expect Supabase alone (no icon), no admonition. ```json { "name": "Acme Tools", "website": "https://evil.com", "icon": null, "domain": "evil.com", "redirect_uri": "https://evil.com/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` | Preview | | --- | | <img width="764" height="958" alt="Authorize Acme Tools Supabase" src="https://github.com/user-attachments/assets/dab24817-5c26-4aa1-a447-796c4af5868b" /> | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Added an OAuth caution when a requester name matches a known partner but uses an unapproved remote redirect host. - Improved trusted partner logo selection for localhost/loopback redirects while preserving safe fallbacks for untrusted redirects. - **Documentation** - Updated Connect interstitial guidance for redirect mismatches and localhost/loopback behavior. - **Tests** - Expanded coverage for caution visibility, messaging, localhost logo pairing, and trusted redirect scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
175 lines
5.1 KiB
TypeScript
175 lines
5.1 KiB
TypeScript
import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder'
|
|
|
|
export type TrustedOAuthPartner = {
|
|
/** Substrings matched against the requester name (case-insensitive). */
|
|
nameMatchers: readonly string[]
|
|
displayName: string
|
|
icon: string
|
|
hasDistinctDarkIcon: boolean
|
|
/** Exact host or parent host for redirect_uri (subdomains allowed). */
|
|
redirectHosts: readonly string[]
|
|
}
|
|
|
|
/**
|
|
* High-traffic MCP / OAuth partners with curated Connect logos.
|
|
* Logos resolve from allowlisted redirect_uri hosts, or from a trusted name when
|
|
* redirect_uri is localhost / loopback (common for local MCP clients).
|
|
* Never from self-asserted name alone on a remote host.
|
|
*/
|
|
export const TRUSTED_OAUTH_PARTNERS: readonly TrustedOAuthPartner[] = [
|
|
{
|
|
nameMatchers: ['claude'],
|
|
displayName: 'Claude',
|
|
icon: 'claude',
|
|
hasDistinctDarkIcon: false,
|
|
redirectHosts: ['claude.ai', 'anthropic.com'],
|
|
},
|
|
{
|
|
nameMatchers: ['cursor'],
|
|
displayName: 'Cursor',
|
|
icon: 'cursor',
|
|
hasDistinctDarkIcon: true,
|
|
redirectHosts: ['cursor.com', 'cursor.sh'],
|
|
},
|
|
{
|
|
nameMatchers: ['chatgpt', 'openai'],
|
|
displayName: 'ChatGPT',
|
|
icon: 'openai',
|
|
hasDistinctDarkIcon: true,
|
|
redirectHosts: ['chatgpt.com', 'openai.com'],
|
|
},
|
|
{
|
|
nameMatchers: ['perplexity'],
|
|
displayName: 'Perplexity',
|
|
icon: 'perplexity',
|
|
hasDistinctDarkIcon: true,
|
|
redirectHosts: ['perplexity.ai'],
|
|
},
|
|
]
|
|
|
|
const LOCAL_REDIRECT_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
|
|
|
|
export function getRedirectHostname(redirectUri: string | null | undefined): string | null {
|
|
if (!redirectUri) return null
|
|
try {
|
|
const { hostname } = new URL(redirectUri)
|
|
return hostname.toLowerCase() || null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function isLocalRedirectHost(hostname: string | null | undefined): boolean {
|
|
if (!hostname) return false
|
|
const host = hostname.toLowerCase()
|
|
return LOCAL_REDIRECT_HOSTS.has(host) || host.endsWith('.localhost')
|
|
}
|
|
|
|
export function hostMatchesAllowlist(hostname: string, allowedHosts: readonly string[]): boolean {
|
|
const host = hostname.toLowerCase()
|
|
return allowedHosts.some((allowed) => {
|
|
const entry = allowed.toLowerCase()
|
|
return host === entry || host.endsWith(`.${entry}`)
|
|
})
|
|
}
|
|
|
|
export function findTrustedPartnerByRedirectUri(
|
|
redirectUri: string | null | undefined
|
|
): TrustedOAuthPartner | null {
|
|
const hostname = getRedirectHostname(redirectUri)
|
|
if (!hostname || isLocalRedirectHost(hostname)) return null
|
|
|
|
return (
|
|
TRUSTED_OAUTH_PARTNERS.find((partner) =>
|
|
hostMatchesAllowlist(hostname, partner.redirectHosts)
|
|
) ?? null
|
|
)
|
|
}
|
|
|
|
export function findTrustedPartnerByName(name: string): TrustedOAuthPartner | null {
|
|
const searchable = name.toLowerCase()
|
|
return (
|
|
TRUSTED_OAUTH_PARTNERS.find((partner) =>
|
|
partner.nameMatchers.some((matcher) => searchable.includes(matcher))
|
|
) ?? null
|
|
)
|
|
}
|
|
|
|
function curatedLogoForPartner(
|
|
partner: TrustedOAuthPartner,
|
|
useDarkVariant: boolean
|
|
): { src: string; isKnownClient: boolean } | null {
|
|
const customLogoUrl = getMcpClientIconSrc({
|
|
icon: partner.icon,
|
|
useDarkVariant,
|
|
hasDistinctDarkIcon: partner.hasDistinctDarkIcon,
|
|
})
|
|
if (!customLogoUrl) return null
|
|
return { src: customLogoUrl, isKnownClient: true }
|
|
}
|
|
|
|
export function getRequesterLogo({
|
|
icon,
|
|
name,
|
|
redirectUri,
|
|
useDarkVariant,
|
|
}: {
|
|
icon: string | null
|
|
name?: string | null
|
|
redirectUri: string | null | undefined
|
|
useDarkVariant: boolean
|
|
}): { src: string; isKnownClient: boolean } {
|
|
const byRedirect = findTrustedPartnerByRedirectUri(redirectUri)
|
|
if (byRedirect) {
|
|
const curated = curatedLogoForPartner(byRedirect, useDarkVariant)
|
|
if (curated) return curated
|
|
}
|
|
|
|
// Local MCP clients (Claude Desktop, Cursor, etc.) use loopback redirects.
|
|
// Name match is enough there — remote hosts still require the allowlist.
|
|
const hostname = getRedirectHostname(redirectUri)
|
|
if (hostname && isLocalRedirectHost(hostname) && name) {
|
|
const byName = findTrustedPartnerByName(name)
|
|
if (byName) {
|
|
const curated = curatedLogoForPartner(byName, useDarkVariant)
|
|
if (curated) return curated
|
|
}
|
|
}
|
|
|
|
return { src: icon || '', isKnownClient: false }
|
|
}
|
|
|
|
export type OAuthImpersonationWarning = {
|
|
/** Trusted partner label used in the caution copy. */
|
|
brandDisplayName: string
|
|
redirectHost: string
|
|
}
|
|
|
|
/**
|
|
* Warn when the requester name looks like a known partner but redirect_uri is a
|
|
* remote host outside that partner's allowlist. Localhost redirects are skipped
|
|
* (common for local MCP clients). Missing or malformed redirect URIs are skipped.
|
|
*/
|
|
export function getOAuthImpersonationWarning({
|
|
name,
|
|
redirectUri,
|
|
}: {
|
|
name: string
|
|
redirectUri: string | null | undefined
|
|
}): OAuthImpersonationWarning | null {
|
|
const namedPartner = findTrustedPartnerByName(name)
|
|
if (!namedPartner) return null
|
|
|
|
const hostname = getRedirectHostname(redirectUri)
|
|
if (!hostname || isLocalRedirectHost(hostname)) return null
|
|
|
|
if (hostMatchesAllowlist(hostname, namedPartner.redirectHosts)) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
brandDisplayName: namedPartner.displayName,
|
|
redirectHost: hostname,
|
|
}
|
|
}
|