Files
supabase/apps/studio/tests/components/ApiAuthorization.test.tsx
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

529 lines
20 KiB
TypeScript

import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { platformComponents as components } from 'api-types'
import dayjs from 'dayjs'
import { HttpResponse } from 'msw'
import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder'
import { describe, expect, test, vi } from 'vitest'
import {
ApiAuthorizationScreen,
type ApiAuthorizationScreenProps,
} from '@/components/interfaces/ApiAuthorization/ApiAuthorization'
import { AuthorizeConnectLogo } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
import type { ProfileContextType } from '@/lib/profile'
import { createMockOrganizationResponse } from '@/tests/helpers'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
type OrganizationResponse = components['schemas']['OrganizationResponse']
type GetOAuthAuthorizationResponse = components['schemas']['GetOAuthAuthorizationResponse']
type ApproveAuthorizationResponse = components['schemas']['ApproveAuthorizationResponse']
type DeclineAuthorizationResponse = components['schemas']['DeclineAuthorizationResponse']
// --- Fixtures ---
const DEFAULT_PROFILE_CONTEXT: ProfileContextType = {
profile: {
id: 1,
auth0_id: 'auth0|test',
gotrue_id: 'gotrue-test',
username: 'testuser',
primary_email: 'test@example.com',
first_name: null,
last_name: null,
mobile: null,
is_alpha_user: false,
is_sso_user: false,
disabled_features: [],
free_project_limit: null,
},
error: null,
isLoading: false,
isError: false,
isSuccess: true,
}
function createMockAuthResponse(
overrides: Partial<ApiAuthorizationResponse> = {}
): ApiAuthorizationResponse {
return {
name: 'Test App',
website: 'https://testapp.com',
icon: null,
domain: 'testapp.com',
scopes: [],
expires_at: dayjs().add(1, 'hour').toISOString(),
approved_at: null,
registration_type: 'static',
...overrides,
}
}
const DEFAULT_ORG = createMockOrganizationResponse({ name: 'My Org', slug: 'my-org' })
const SECOND_ORG = createMockOrganizationResponse({
id: 2,
name: 'Second Org',
slug: 'second-org',
})
// --- MSW helpers ---
// Both the auth query and the organizations query fire for any valid auth_id render.
// Since MSW is configured with onUnhandledRequest: 'error', both must always be mocked.
function mockAuthEndpoint(authResponse: ApiAuthorizationResponse) {
addAPIMock({
method: 'get',
path: '/platform/oauth/authorizations/:id',
// The frontend `ApiAuthorizationResponse` widens the OpenAPI shape
// (looser `registration_type`, nullable `icon`/`approved_at`), and the
// query layer casts via `data as ApiAuthorizationResponse`. Cast here to
// satisfy the network-boundary contract.
response: () =>
HttpResponse.json<GetOAuthAuthorizationResponse>(
authResponse as unknown as GetOAuthAuthorizationResponse
),
})
}
function mockOrgsEndpoint(orgs: OrganizationResponse[] = [DEFAULT_ORG]) {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () => HttpResponse.json<OrganizationResponse[]>(orgs),
})
}
function mockBothEndpoints(
authResponse: ApiAuthorizationResponse = createMockAuthResponse(),
orgs: OrganizationResponse[] = [DEFAULT_ORG]
) {
mockAuthEndpoint(authResponse)
mockOrgsEndpoint(orgs)
}
// --- Render helper ---
function renderScreen(props: Partial<ApiAuthorizationScreenProps> = {}) {
const navigate = vi.fn()
const result = customRender(
<ApiAuthorizationScreen
auth_id="test-auth-id"
organization_slug={undefined}
navigate={navigate}
{...props}
/>,
{ profileContext: DEFAULT_PROFILE_CONTEXT }
)
return { ...result, navigate }
}
// --- Tests ---
describe('AuthorizeConnectLogo', () => {
test.each([
['Cursor', 'https://cursor.com/callback', 'cursor'],
['Claude', 'https://claude.ai/api/mcp/auth_callback', 'claude'],
['ChatGPT', 'https://chatgpt.com/callback', 'openai'],
['OpenAI', 'https://openai.com/callback', 'openai'],
['Perplexity', 'https://www.perplexity.ai/callback', 'perplexity'],
])('pairs %s with Supabase when redirect host is allowlisted', (name, redirectUri, iconKey) => {
customRender(<AuthorizeConnectLogo icon={null} name={name} redirectUri={redirectUri} />)
expect(screen.getByAltText(name)).toHaveAttribute(
'src',
getMcpClientIconSrc({ icon: iconKey, useDarkVariant: false })
)
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
})
test('does not use a curated logo from the requester name alone', () => {
customRender(
<AuthorizeConnectLogo icon={null} name="Claude" redirectUri="https://evil.com/callback" />
)
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
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" />)
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
expect(screen.queryByAltText('Acme')).not.toBeInTheDocument()
expect(screen.queryByText('A')).not.toBeInTheDocument()
})
test('shows Supabase alone when the requester icon fails to load', () => {
customRender(
<AuthorizeConnectLogo icon="https://example.com/broken-logo.svg" name="Unknown App" />
)
fireEvent.error(screen.getByAltText('Unknown App'))
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
expect(screen.queryByAltText('Unknown App')).not.toBeInTheDocument()
expect(screen.queryByText('U')).not.toBeInTheDocument()
})
test('forces light tiles when pairing an uploaded OAuth app icon', () => {
customRender(
<AuthorizeConnectLogo
icon="https://example.com/uploaded-icon.png"
name="Acme"
redirectUri="https://acme.example/callback"
/>
)
expect(screen.getByAltText('Acme').parentElement).toHaveClass('bg-white')
expect(screen.getByAltText('Acme').parentElement).toHaveClass('border-black/10')
expect(screen.getByAltText('Supabase').parentElement).toHaveClass('bg-white')
expect(screen.getByAltText('Supabase').parentElement).toHaveClass('border-black/10')
})
test('keeps theme tiles for curated partners', () => {
customRender(
<AuthorizeConnectLogo icon={null} name="Cursor" redirectUri="https://cursor.com/callback" />
)
expect(screen.getByAltText('Cursor').parentElement).toHaveClass('bg-surface-75')
expect(screen.getByAltText('Supabase').parentElement).toHaveClass('bg-surface-75')
})
})
describe('ApiAuthorizationScreen', () => {
describe('when auth_id is missing', () => {
test('renders invalid interstitial when auth_id is undefined', () => {
renderScreen({ auth_id: undefined })
expect(screen.getByText('Missing authorization link')).toBeInTheDocument()
expect(screen.getByText(/auth_id/)).toBeInTheDocument()
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Back to dashboard' })).toHaveAttribute('href', '/')
})
})
describe('when auth_id is provided', () => {
test('renders loading screen while authorization data is being fetched', () => {
mockOrgsEndpoint()
addAPIMock({
method: 'get',
path: '/platform/oauth/authorizations/:id',
response: () => new Promise<never>(() => {}),
})
const { container } = renderScreen()
expect(screen.getByText('Loading...')).toBeInTheDocument()
expect(container.querySelectorAll('.shimmering-loader').length).toBeGreaterThan(0)
})
test('renders error screen when authorization query fails', async () => {
mockOrgsEndpoint()
addAPIMock({
method: 'get',
path: '/platform/oauth/authorizations/:id',
response: () => HttpResponse.json<APIErrorBody>({ message: 'Not found' }, { status: 404 }),
})
renderScreen()
await screen.findByText('Unable to load authorization')
})
test('renders error screen when authorization query returns no requester', async () => {
mockOrgsEndpoint()
addAPIMock({
method: 'get',
path: '/platform/oauth/authorizations/:id',
response: () =>
HttpResponse.json<GetOAuthAuthorizationResponse>(
null as unknown as GetOAuthAuthorizationResponse
),
})
renderScreen()
await screen.findByText('Unable to load authorization')
})
describe('when already approved', () => {
test('renders approved screen with matching organization name', async () => {
mockBothEndpoints(
createMockAuthResponse({
approved_at: '2025-01-15T10:00:00Z',
approved_organization_slug: 'my-org',
})
)
renderScreen()
await screen.findByText('Authorization approved')
expect(screen.getByText(/has access to My Org/)).toBeInTheDocument()
})
test('shows Unknown when approved organization is not in the user organizations list', async () => {
mockBothEndpoints(
createMockAuthResponse({
approved_at: '2025-01-15T10:00:00Z',
approved_organization_slug: 'other-org',
})
)
renderScreen()
await screen.findByText('Authorization approved')
expect(screen.getByText(/has access to Unknown/)).toBeInTheDocument()
})
})
describe('main authorization form', () => {
describe('organizations states', () => {
test('does not show action buttons while organizations are being fetched', async () => {
mockAuthEndpoint(createMockAuthResponse())
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () => new Promise<never>(() => {}),
})
renderScreen()
await screen.findByText('Authorize API access for Test App')
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Authorize Test App/ })
).not.toBeInTheDocument()
})
test('shows error notice and hides action buttons when organizations query fails', async () => {
mockAuthEndpoint(createMockAuthResponse())
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () =>
HttpResponse.json<APIErrorBody>({ message: 'Server error' }, { status: 500 }),
})
renderScreen()
await screen.findByText('Unable to load organizations')
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Authorize Test App/ })
).not.toBeInTheDocument()
})
test('shows empty state when user has no organizations', async () => {
mockBothEndpoints(createMockAuthResponse(), [])
renderScreen()
await screen.findByText('No organizations found')
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Authorize Test App/ })
).not.toBeInTheDocument()
})
test('shows not_member notice when organization_slug does not match any user organization', async () => {
mockBothEndpoints()
renderScreen({ organization_slug: 'nonexistent-org' })
await screen.findByText(/Your account is not a member of the pre-selected organization/)
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Authorize Test App/ })
).not.toBeInTheDocument()
})
})
describe('success state with organization selector', () => {
test('renders form with organization selector and action buttons', async () => {
mockBothEndpoints(createMockAuthResponse({ name: 'My OAuth App' }))
renderScreen()
await screen.findByText('Authorize API access for My OAuth App')
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
expect(screen.queryByText('M')).not.toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Authorize My OAuth App/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
})
test('pairs curated MCP requesters with Supabase when redirect host is allowlisted', async () => {
mockBothEndpoints(
createMockAuthResponse({
name: 'Cursor',
icon: null,
redirect_uri: 'https://cursor.com/callback',
})
)
renderScreen()
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('warns when a trusted name redirects to a non-allowlisted host', async () => {
mockBothEndpoints(
createMockAuthResponse({
name: 'Claude',
icon: null,
redirect_uri: 'https://evil.com/callback',
})
)
renderScreen()
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()
})
test('auto-selects the only organization when no organization_slug is provided', async () => {
mockBothEndpoints()
renderScreen()
const combobox = await screen.findByRole('combobox')
expect(combobox).toHaveTextContent('My Org')
})
test('pre-selects organization when organization_slug matches a user organization', async () => {
mockBothEndpoints(createMockAuthResponse(), [DEFAULT_ORG, SECOND_ORG])
renderScreen({ organization_slug: 'second-org' })
const combobox = await screen.findByRole('combobox')
expect(combobox).toHaveTextContent('Second Org')
expect(combobox).not.toHaveTextContent('My Org')
expect(screen.getByText('Pre-selected by Test App')).toBeInTheDocument()
})
})
describe('expiration', () => {
test('shows expiration warning and hides action buttons when request has expired', async () => {
mockBothEndpoints(
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.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 () => {
mockBothEndpoints()
renderScreen()
await screen.findByText('Authorize API access for Test App')
expect(screen.queryByText('Authorization request expired')).not.toBeInTheDocument()
})
})
describe('approve action', () => {
test('calls approve endpoint when Authorize button is clicked', async () => {
const user = userEvent.setup()
const approveHandler = vi.fn(() =>
HttpResponse.json<ApproveAuthorizationResponse>({
url: 'https://redirect.example.com',
})
)
mockBothEndpoints()
addAPIMock({
method: 'post',
path: '/platform/organizations/:slug/oauth/authorizations/:id',
response: approveHandler,
})
renderScreen()
await screen.findByRole('button', { name: /Authorize Test App/ })
await user.click(screen.getByRole('button', { name: /Authorize Test App/ }))
await waitFor(() => expect(approveHandler).toHaveBeenCalled())
})
test('shows an approval failure inline and keeps the action available', async () => {
const user = userEvent.setup()
mockBothEndpoints()
addAPIMock({
method: 'post',
path: '/platform/organizations/:slug/oauth/authorizations/:id',
response: () =>
HttpResponse.json<APIErrorBody>({ message: 'Authorization failed' }, { status: 500 }),
})
renderScreen()
await user.click(await screen.findByRole('button', { name: /Authorize Test App/ }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Failed to authorize request: Authorization failed'
)
expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeEnabled()
})
})
describe('decline action', () => {
test('navigates to /organizations after cancelling', async () => {
const user = userEvent.setup()
const declineHandler = vi.fn(() =>
HttpResponse.json<DeclineAuthorizationResponse>({ id: 'test-auth-id' })
)
mockBothEndpoints()
addAPIMock({
method: 'delete',
path: '/platform/organizations/:slug/oauth/authorizations/:id',
response: declineHandler,
})
const { navigate } = renderScreen()
await screen.findByRole('button', { name: 'Cancel' })
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(declineHandler).toHaveBeenCalled())
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations'))
})
test('shows a cancellation failure inline and keeps the action available', async () => {
const user = userEvent.setup()
mockBothEndpoints()
addAPIMock({
method: 'delete',
path: '/platform/organizations/:slug/oauth/authorizations/:id',
response: () =>
HttpResponse.json<APIErrorBody>({ message: 'Cancellation failed' }, { status: 500 }),
})
renderScreen()
await user.click(await screen.findByRole('button', { name: 'Cancel' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Failed to cancel authorization request: Cancellation failed'
)
expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled()
})
})
describe('form validation', () => {
test('shows validation error when Authorize is clicked without selecting an organization', async () => {
const user = userEvent.setup()
// Two orgs → no auto-selection, user must pick one manually
mockBothEndpoints(createMockAuthResponse(), [DEFAULT_ORG, SECOND_ORG])
renderScreen()
await screen.findByRole('button', { name: /Authorize Test App/ })
await user.click(screen.getByRole('button', { name: /Authorize Test App/ }))
expect(
(await screen.findAllByText('Please select an organization')).length
).toBeGreaterThan(0)
})
})
})
})
})