mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
## What kind of change does this PR introduce? Feature + docs. Closes [DEPR-604](https://linear.app/supabase/issue/DEPR-604/define-connect-logo-asset-and-variant-contract). ## What is the current behavior? `/authorize` logo resolution trusted self-asserted requester `name` (and similar) for curated MCP marks, fell back to a letter tile when there was no usable icon, and always used theme-reactive tile chrome. This includes the scenario when pairing against unclassified uploaded OAuth app bitmaps. ## What is the new behavior? - [Documents the Connect logo asset/variant contract](https://design-system-git-danny-depr-604-connect-logo-contract-supabase.vercel.app/design-system/docs/ui-patterns/connect-interstitials#logos) (default to light, keep pairs matched, no theme-recolour of vendor SVGs). - Resolves curated partner logos from allowlisted `redirect_uri` hosts only (`claude.ai` / `anthropic.com`, `cursor.com` / `cursor.sh`, `chatgpt.com` / `openai.com`, `perplexity.ai`). - Unknown / missing / failed requester icons show `SupabaseLogo` alone (no letter tile). - Uploaded organisation OAuth app icons (unclassified bitmaps) pair with fixed light tile chrome (`border-black/10 bg-white` / `SupabaseLogo forceLight`) on both sides across Studio themes. - Curated partners keep theme-reactive tiles and may use dark assets when available. ### 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 once available). 2. Open `/dashboard/authorize?auth_id=foo` (any `auth_id` is fine — the real response may 404). 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. The fields that matter for this PR are `name`, `icon`, and `redirect_uri`. #### Curated pair (allowlisted redirect) Expect Cursor mark + Supabase pair. Toggle light/dark: curated dark assets may swap; tiles stay theme-reactive (`bg-surface-75`). ```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" } ``` #### Unknown → Supabase alone Expect Supabase bolt alone. No letter tile. No curated mark even if `name` says Claude. ```json { "name": "Acme", "website": "https://acme.example", "icon": null, "domain": "acme.example", "redirect_uri": "https://acme.example/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` #### Spoofed trusted name, non-allowlisted redirect (logo only) Expect Supabase alone (no Claude mark). This PR does **not** show the impersonation caution (that is coming in #48162). ```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" } ``` #### Uploaded OAuth app icon → forced-light pair Expect remote icon + Supabase pair with forced-light tiles (`border-black/10 bg-white`) on both sides in light and dark Studio themes. The icon URL below is the checked-in solid-colour Acme bitmap on this branch. ```json { "name": "Acme", "website": "https://acme.example", "icon": "https://raw.githubusercontent.com/supabase/supabase/danny/depr-604-connect-logo-contract/apps/design-system/public/img/icons/acme-oauth-icon.png", "domain": "acme.example", "redirect_uri": "https://acme.example/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "static" } ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Improved authorization interstitial branding with trusted requester logos and safer fallback behavior. * Added support for consistent light-theme treatment of uploaded OAuth app icons. * Added examples and documentation for unknown requesters, uploaded logos, and wrong-account states. * **Bug Fixes** * Prevented unverified or unavailable requester icons from being presented as trusted. * Ensured logo pairing remains visually consistent across light and dark themes. * **Tests** * Added coverage for trusted-host validation, fallback branding, icon loading failures, and theme behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
102 lines
2.8 KiB
TypeScript
102 lines
2.8 KiB
TypeScript
import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder'
|
|
|
|
export type TrustedOAuthPartner = {
|
|
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 redirect_uri host only — never from self-asserted name/website.
|
|
*/
|
|
export const TRUSTED_OAUTH_PARTNERS: readonly TrustedOAuthPartner[] = [
|
|
{
|
|
displayName: 'Claude',
|
|
icon: 'claude',
|
|
hasDistinctDarkIcon: false,
|
|
redirectHosts: ['claude.ai', 'anthropic.com'],
|
|
},
|
|
{
|
|
displayName: 'Cursor',
|
|
icon: 'cursor',
|
|
hasDistinctDarkIcon: true,
|
|
redirectHosts: ['cursor.com', 'cursor.sh'],
|
|
},
|
|
{
|
|
displayName: 'ChatGPT',
|
|
icon: 'openai',
|
|
hasDistinctDarkIcon: true,
|
|
redirectHosts: ['chatgpt.com', 'openai.com'],
|
|
},
|
|
{
|
|
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 getRequesterLogo({
|
|
icon,
|
|
redirectUri,
|
|
useDarkVariant,
|
|
}: {
|
|
icon: string | null
|
|
redirectUri: string | null | undefined
|
|
useDarkVariant: boolean
|
|
}): { src: string; isKnownClient: boolean } {
|
|
const trusted = findTrustedPartnerByRedirectUri(redirectUri)
|
|
if (trusted) {
|
|
const customLogoUrl = getMcpClientIconSrc({
|
|
icon: trusted.icon,
|
|
useDarkVariant,
|
|
hasDistinctDarkIcon: trusted.hasDistinctDarkIcon,
|
|
})
|
|
if (customLogoUrl) return { src: customLogoUrl, isKnownClient: true }
|
|
}
|
|
|
|
return { src: icon || '', isKnownClient: false }
|
|
}
|