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 -->
This commit is contained in:
Danny White authored and GitHub committed 2026-08-02 23:57:27 +00:00
1 parent 2b26da360e
commit f7454cf94e
6 files changed
+249 -26

No files matched your search

@@ -165,12 +165,17 @@ the pair should use the dark set together.
**Where logos come from on `/authorize`**
- Curated partner logos resolve from allowlisted `redirect_uri` hosts only,
not from self-asserted `name` or `website`. Those pairs may use theme tiles
and dark assets when the partner has them.
- Curated partner logos resolve from allowlisted `redirect_uri` hosts, or from
a trusted partner name when `redirect_uri` is localhost / loopback (local MCP
clients). Do not resolve curated logos from self-asserted `name` or `website`
on a remote host. Those pairs may use theme tiles and dark assets when the
partner has them.
- Published organisation OAuth app icons uploaded in Studio remain trusted
remote images, paired with forced-light tiles on both sides.
- Everything else falls back to `SupabaseLogo` alone.
- If the requester name looks like a known partner but `redirect_uri` is a
remote host outside that partner's allowlist, show a caution admonition.
Localhost MCP redirects are excluded.
## Account row
@@ -21,8 +21,10 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import type { ApprovalState, IApprovalFormSchema } from './ApiAuthorization.Schema'
import {
AuthorizeConnectLogo,
AuthorizeImpersonationWarning,
AuthorizeRequesterDetails,
} from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
import { getOAuthImpersonationWarning } from '@/components/interfaces/Organization/OAuthApps/OAuthApps.utils'
import {
InterstitialActionError,
InterstitialLayout,
@@ -115,6 +117,10 @@ export function ApiAuthorizationMainView({
<ExpiredNotice />
) : (
<>
<AuthorizeImpersonationWarning
name={requester.name}
redirectUri={requester.redirect_uri}
/>
{organizations._tag === 'loading' && <OrganizationsLoader />}
{organizations._tag === 'error' && (
<OrganizationsErrorNotice error={organizations.error} />
@@ -312,6 +318,13 @@ function FormFooter({
onDecline,
onApprove,
}: FormFooterProps): ReactNode {
const hasImpersonationWarning = Boolean(
getOAuthImpersonationWarning({
name: requester.name,
redirectUri: requester.redirect_uri,
})
)
return (
<div className="flex flex-col gap-2">
<ApprovalButton
@@ -333,7 +346,10 @@ function FormFooter({
{!actionError && redirectUrl && (
<div className="mt-3 border-t border-muted pt-5">
<p className="text-center text-xs text-foreground-lighter text-balance">
Authorizing will redirect you to <span className="text-foreground">{redirectUrl}</span>
Authorizing will redirect you to{' '}
<span className={hasImpersonationWarning ? 'text-warning' : 'text-foreground'}>
{redirectUrl}
</span>
</p>
</div>
)}
@@ -11,10 +11,11 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { PERMISSIONS_DESCRIPTIONS } from './OAuthApps.constants'
import { getRequesterLogo } from './OAuthApps.utils'
import { getOAuthImpersonationWarning, getRequesterLogo } from './OAuthApps.utils'
import {
CONNECT_LOGO_LIGHT_TILE_CLASSNAME,
LogoBox,
@@ -196,10 +197,11 @@ export const AuthorizeConnectLogo = ({
() =>
getRequesterLogo({
icon,
name,
redirectUri,
useDarkVariant: resolvedTheme === 'dark',
}),
[icon, redirectUri, resolvedTheme]
[icon, name, redirectUri, resolvedTheme]
)
const hasUsableLogo = Boolean(logo.src) && failedIcon !== logo.src
@@ -227,6 +229,25 @@ export const AuthorizeConnectLogo = ({
)
}
export const AuthorizeImpersonationWarning = ({
name,
redirectUri,
}: {
name: string
redirectUri?: string | null
}) => {
const warning = getOAuthImpersonationWarning({ name, redirectUri })
if (!warning) return null
return (
<Admonition
type="caution"
title="Check this redirect before authorizing"
description={`This request uses the name ${warning.brandDisplayName}, but after you authorize you will be redirected to ${warning.redirectHost}, not ${warning.brandDisplayName}.`}
/>
)
}
export const AuthorizeRequesterDetails = ({
name,
scopes,
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest'
import {
findTrustedPartnerByRedirectUri,
getOAuthImpersonationWarning,
getRedirectHostname,
getRequesterLogo,
hostMatchesAllowlist,
@@ -60,9 +61,10 @@ describe('findTrustedPartnerByRedirectUri', () => {
})
describe('getRequesterLogo', () => {
test('uses curated assets only when redirect host is allowlisted', () => {
test('uses curated assets when redirect host is allowlisted', () => {
const trusted = getRequesterLogo({
icon: null,
name: 'Claude',
redirectUri: 'https://claude.ai/api/mcp/auth_callback',
useDarkVariant: false,
})
@@ -70,22 +72,97 @@ describe('getRequesterLogo', () => {
src: getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }),
isKnownClient: true,
})
})
const namedOnly = getRequesterLogo({
icon: null,
redirectUri: 'https://evil.com/callback',
useDarkVariant: false,
test('uses curated assets for localhost when the name matches a trusted partner', () => {
expect(
getRequesterLogo({
icon: null,
name: 'Claude',
redirectUri: 'http://127.0.0.1:42813/callback',
useDarkVariant: false,
})
).toEqual({
src: getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }),
isKnownClient: true,
})
expect(namedOnly).toEqual({ src: '', isKnownClient: false })
})
test('does not use curated assets from name alone on a remote host', () => {
expect(
getRequesterLogo({
icon: null,
name: 'Claude',
redirectUri: 'https://evil.com/callback',
useDarkVariant: false,
})
).toEqual({ src: '', isKnownClient: false })
})
test('falls back to the supplied icon URL when redirect is not trusted', () => {
expect(
getRequesterLogo({
icon: 'https://example.com/icon.png',
name: 'Acme',
redirectUri: 'https://evil.com/callback',
useDarkVariant: false,
})
).toEqual({ src: 'https://example.com/icon.png', isKnownClient: false })
})
})
describe('getOAuthImpersonationWarning', () => {
test('warns when a trusted name redirects to a remote non-allowlisted host', () => {
expect(
getOAuthImpersonationWarning({
name: 'Claude Desktop',
redirectUri: 'https://evil.com/callback',
})
).toEqual({
brandDisplayName: 'Claude',
redirectHost: 'evil.com',
})
})
test('skips localhost MCP redirects', () => {
expect(
getOAuthImpersonationWarning({
name: 'Claude',
redirectUri: 'http://127.0.0.1:42813/callback',
})
).toBe(null)
})
test('skips when redirect host matches the named partner', () => {
expect(
getOAuthImpersonationWarning({
name: 'Claude',
redirectUri: 'https://claude.ai/api/mcp/auth_callback',
})
).toBe(null)
})
test('skips when the name does not match a trusted partner', () => {
expect(
getOAuthImpersonationWarning({
name: 'Acme Tools',
redirectUri: 'https://evil.com/callback',
})
).toBe(null)
})
test('skips missing or unparsable redirect URIs', () => {
expect(
getOAuthImpersonationWarning({
name: 'Claude',
redirectUri: null,
})
).toBe(null)
expect(
getOAuthImpersonationWarning({
name: 'Claude',
redirectUri: 'not-a-url',
})
).toBe(null)
})
})
@@ -1,6 +1,8 @@
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
@@ -10,28 +12,34 @@ export type TrustedOAuthPartner = {
/**
* High-traffic MCP / OAuth partners with curated Connect logos.
* Logos resolve from redirect_uri host only — never from self-asserted name/website.
* 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,
@@ -78,24 +86,89 @@ export function findTrustedPartnerByRedirectUri(
)
}
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 trusted = findTrustedPartnerByRedirectUri(redirectUri)
if (trusted) {
const customLogoUrl = getMcpClientIconSrc({
icon: trusted.icon,
useDarkVariant,
hasDistinctDarkIcon: trusted.hasDistinctDarkIcon,
})
if (customLogoUrl) return { src: customLogoUrl, isKnownClient: true }
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,
}
}
@@ -148,6 +148,22 @@ describe('AuthorizeConnectLogo', () => {
expect(screen.queryByAltText('Claude')).not.toBeInTheDocument()
})
test('pairs curated logos for localhost when the name matches a trusted partner', () => {
customRender(
<AuthorizeConnectLogo
icon={null}
name="Claude"
redirectUri="http://127.0.0.1:42813/callback"
/>
)
expect(screen.getByAltText('Claude')).toHaveAttribute(
'src',
getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false })
)
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
})
test('shows Supabase alone when the requester has no icon', () => {
customRender(<AuthorizeConnectLogo icon={null} name="Acme" />)
@@ -346,9 +362,12 @@ describe('ApiAuthorizationScreen', () => {
await screen.findByText('Authorize API access for Cursor')
expect(screen.getByAltText('Cursor')).toBeInTheDocument()
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
expect(
screen.queryByText('Check this redirect before authorizing')
).not.toBeInTheDocument()
})
test('shows Supabase alone when name looks trusted but redirect host is not allowlisted', async () => {
test('warns when a trusted name redirects to a non-allowlisted host', async () => {
mockBothEndpoints(
createMockAuthResponse({
name: 'Claude',
@@ -357,7 +376,14 @@ describe('ApiAuthorizationScreen', () => {
})
)
renderScreen()
await screen.findByText('Authorize API access for Claude')
expect(
await screen.findByText('Check this redirect before authorizing')
).toBeInTheDocument()
expect(
screen.getByText(
'This request uses the name Claude, but after you authorize you will be redirected to evil.com, not Claude.'
)
).toBeInTheDocument()
expect(screen.queryByAltText('Claude')).not.toBeInTheDocument()
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
})
@@ -382,14 +408,19 @@ describe('ApiAuthorizationScreen', () => {
describe('expiration', () => {
test('shows expiration warning and hides action buttons when request has expired', async () => {
mockBothEndpoints(
createMockAuthResponse({ expires_at: dayjs().subtract(1, 'hour').toISOString() })
createMockAuthResponse({
name: 'Claude',
redirect_uri: 'https://evil.com/callback',
expires_at: dayjs().subtract(1, 'hour').toISOString(),
})
)
renderScreen()
await screen.findByText('Authorization request expired')
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Authorize Test App/ })
screen.queryByText('Check this redirect before authorizing')
).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Authorize Claude/ })).not.toBeInTheDocument()
})
test('does not show expiration warning when request has not expired', async () => {