Files
Danny White f7454cf94e feat(studio): oauth impersonation warning on authorize (#48162)
## 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 -->
2026-08-02 23:57:27 +00:00

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,
}
}