mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
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>
|
||||
)}
|
||||
|
||||
+23
-2
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user