Files
supabase/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts
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

169 lines
4.7 KiB
TypeScript

import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder'
import { describe, expect, test } from 'vitest'
import {
findTrustedPartnerByRedirectUri,
getOAuthImpersonationWarning,
getRedirectHostname,
getRequesterLogo,
hostMatchesAllowlist,
isLocalRedirectHost,
} from './OAuthApps.utils'
describe('hostMatchesAllowlist', () => {
test('allows exact and subdomain hosts', () => {
expect(hostMatchesAllowlist('claude.ai', ['claude.ai'])).toBe(true)
expect(hostMatchesAllowlist('api.claude.ai', ['claude.ai'])).toBe(true)
})
test('rejects lookalike hosts', () => {
expect(hostMatchesAllowlist('claude.ai.evil.com', ['claude.ai'])).toBe(false)
expect(hostMatchesAllowlist('notclaude.ai', ['claude.ai'])).toBe(false)
expect(hostMatchesAllowlist('evilclaude.ai', ['claude.ai'])).toBe(false)
})
})
describe('isLocalRedirectHost', () => {
test.each(['localhost', '127.0.0.1', '[::1]', '::1', 'app.localhost'])(
'treats %s as local',
(host) => {
expect(isLocalRedirectHost(host)).toBe(true)
}
)
test('treats public hosts as remote', () => {
expect(isLocalRedirectHost('claude.ai')).toBe(false)
expect(isLocalRedirectHost('evil.com')).toBe(false)
})
})
describe('getRedirectHostname', () => {
test('parses https redirect URIs', () => {
expect(getRedirectHostname('https://claude.ai/api/mcp/auth_callback')).toBe('claude.ai')
})
test('returns null for invalid URIs', () => {
expect(getRedirectHostname('not-a-url')).toBe(null)
expect(getRedirectHostname(null)).toBe(null)
})
})
describe('findTrustedPartnerByRedirectUri', () => {
test('resolves Claude from redirect host', () => {
expect(
findTrustedPartnerByRedirectUri('https://claude.ai/api/mcp/auth_callback')?.displayName
).toBe('Claude')
})
test('ignores localhost redirects', () => {
expect(findTrustedPartnerByRedirectUri('http://127.0.0.1:42813/callback')).toBe(null)
})
})
describe('getRequesterLogo', () => {
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,
})
expect(trusted).toEqual({
src: getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }),
isKnownClient: true,
})
})
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,
})
})
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)
})
})