Files
supabase/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts
Danny WhiteandJoshen Lim e19cd1863d feat(studio): connect logo contract for authorize (#48161)
## 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>
2026-07-23 02:20:42 +10:00

92 lines
2.8 KiB
TypeScript

import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder'
import { describe, expect, test } from 'vitest'
import {
findTrustedPartnerByRedirectUri,
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 only when redirect host is allowlisted', () => {
const trusted = getRequesterLogo({
icon: null,
redirectUri: 'https://claude.ai/api/mcp/auth_callback',
useDarkVariant: false,
})
expect(trusted).toEqual({
src: getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }),
isKnownClient: true,
})
const namedOnly = getRequesterLogo({
icon: null,
redirectUri: 'https://evil.com/callback',
useDarkVariant: false,
})
expect(namedOnly).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',
redirectUri: 'https://evil.com/callback',
useDarkVariant: false,
})
).toEqual({ src: 'https://example.com/icon.png', isKnownClient: false })
})
})