mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05: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>
92 lines
2.8 KiB
TypeScript
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 })
|
|
})
|
|
})
|