Files
supabase/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/IntegrationCard.tsx
T
claude[bot]andClaude 4c7014745c feat(studio): add custom OIDC option to third-party auth providers (#51612)
<!-- claude-slack-attribution -->
_Requested by **Stojan Dimitrovski** · [Slack
thread](https://supabase.slack.com/archives/C022071RB2L/p1791475016175969?thread_ts=1791475016.175969&cid=C022071RB2L)_

## Problem

The Management API accepts any OIDC issuer for third-party auth (`POST
/v1/projects/{ref}/config/auth/third-party-auth` with
`oidc_issuer_url`), but the Dashboard only offers Firebase, Clerk,
WorkOS, Auth0 and Amazon Cognito. Users with another OIDC provider have
no way to register it from the Dashboard.

Studio also hides any issuer it does not recognize: `IntegrationCard`
returns `null` for the `custom` type, so an issuer added through the API
never shows up in the list and cannot be deleted from the Dashboard.

## Solution

- Add a "Custom OIDC" item to the Add provider menu on the Third-Party
Auth page.
- New `CreateCustomOidcDialog` with a single required Issuer URL field.
It reuses `useCreateThirdPartyAuthIntegrationMutation`, so query
invalidation and the error toast behave like the other providers. The
URL is sent as typed after trimming whitespace.
- New `isValidOidcIssuerUrl` helper in `ThirdPartyAuthForm.utils.ts`:
the value must be an `https://` URL with a host, no query string, no
fragment, and no embedded credentials.
- The dialog copy is kept short: a one-line description and a single
helper line noting that signing keys are found at the issuer's
`/.well-known/openid-configuration`.
- `IntegrationCard` now renders custom issuers (badge-check icon,
"Custom OIDC" label, Issuer URL, Delete integration) instead of hiding
them. Delete uses the existing flow and permission check.
- The `custom` label changes from "Custom" to "Custom OIDC".
- New docs page
`apps/docs/content/guides/auth/third-party/custom-oidc.mdx` (served at
`/docs/guides/auth/third-party/custom-oidc`). It covers the Dashboard
flow, the Management API alternative, the `role` claim, client setup,
and RLS. It is linked from the third-party auth overview and added to
the docs sidebar. This supersedes the draft docs PR #51611.

Not included: UI for `jwks_url` and `custom_jwks`. The page and menu
keep the same feature gate (`authentication:third_party_auth`) and the
same permission checks as the other providers.

Before: the Add provider menu lists five providers. An issuer that is
not one of them is registered only through the API and does not appear
in the Dashboard.

After: the menu has a sixth item, Custom OIDC. It opens a dialog that
takes an issuer URL, rejects non-HTTPS values with an inline message,
and registers the issuer. The new provider appears in the list and can
be deleted from there.

## Review instructions

1. Open a project's Authentication > Third-Party Auth page and open Add
provider. Check for the new Custom OIDC item.
2. Enter `http://example.com` and submit. The field shows an inline
error and no request is sent.
3. Enter a valid HTTPS issuer URL that serves an OIDC discovery document
and submit. A success toast appears and the provider shows in the list
as Custom OIDC with its Issuer URL.
4. Enter an issuer the API rejects. The API error shows in the existing
error toast and the dialog stays open.
5. Delete the provider with Delete integration and confirm it disappears
from the list.
6. Read the new docs page and check that its Dashboard steps match the
UI.

Local checks:

- `vitest --run components/interfaces/Auth/ThirdPartyAuthForm`: 20 tests
pass (validation helper unit tests plus dialog component tests with
MSW).
- `tsc --noEmit` in `apps/studio`: no new errors.
- ESLint on the directory: no warnings in the new or changed code.
- Prettier is clean on the changed files, including the docs page. The
typos check is clean on the docs page and overview.

I did not run the dialog in a browser against a real project, so there
are no screenshots. The docs app was not built or rendered locally.

## Checklist

- [x] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [x] If I wrote a new docs topic or edited an existing topic, I used
the `/write-the-docs` or `/edit-the-docs` skill, which applies the docs
[style
guide](https://github.com/supabase/supabase/tree/master/apps/docs/style-guide)

🤖 Generated with [Claude
Code](https://claude.ai/code/session_011HY2zckzPLcvXd28JPSqBe)

https://claude.ai/code/session_011HY2zckzPLcvXd28JPSqBe


---
_Generated by [Claude
Code](https://claude.ai/code/session_011HY2zckzPLcvXd28JPSqBe)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-14 19:01:08 +02:00

240 lines
7.6 KiB
TypeScript

import { CheckIcon } from 'lucide-react'
import Image from 'next/image'
import { Badge, Button } from 'ui'
import { AWS_IDP_REGIONS } from './AwsRegionSelector'
import {
getIntegrationType,
getIntegrationTypeIcon,
getIntegrationTypeLabel,
INTEGRATION_TYPES,
} from './ThirdPartyAuthForm.utils'
import { ThirdPartyAuthIntegration } from '@/data/third-party-auth/integrations-query'
import { DOCS_URL } from '@/lib/constants'
interface IntegrationCardProps {
integration: ThirdPartyAuthIntegration
canUpdateConfig: boolean
onDelete: () => void
}
export const getIntegrationTypeDescription = (type: INTEGRATION_TYPES) => {
switch (type) {
case 'firebase':
return (
<>
Allow users to use Supabase with Firebase project. You'll need to setup RLS policies for
all tables that you want to access with a Firebase JWT token. Additionally, you'll need to
add custom code to set the <code>authenticated</code> role to all your present and future
users. You can read more in the{' '}
<a
className="hover:decoration-primary underline hover:text-foreground transition"
href={`${DOCS_URL}/guides/auth`}
>
documentation
</a>
.
</>
)
case 'auth0':
return (
<>
Allow users to use Supabase with Auth0 project. Additional setup may be required. You can
read more in the{' '}
<a
className="hover:decoration-primary underline hover:text-foreground transition"
href={`${DOCS_URL}/guides/auth`}
>
documentation
</a>
.
</>
)
case 'awsCognito':
return (
<>
Allow users to use Supabase with an Amazon Cognito. Additional setup may be required. You
can read more in the{' '}
<a
className="hover:decoration-primary underline hover:text-foreground transition"
href={`${DOCS_URL}/guides/auth/third-party/aws-cognito`}
>
documentation
</a>
.
</>
)
case 'clerk':
return (
<>
Allow users to use Supabase with Clerk. Additional setup may be required. You can read
more in the{' '}
<a
className="hover:decoration-primary underline hover:text-foreground transition"
href={`${DOCS_URL}/guides/auth/third-party/clerk`}
>
documentation
</a>
.
</>
)
case 'workos':
return (
<>
Allow users to use Supabase with WorkOS. Additional setup may be required. You can read
more in the{' '}
<a
className="hover:decoration-primary underline hover:text-foreground transition"
href={`${DOCS_URL}/guides/auth/third-party/workos`}
>
documentation
</a>
.
</>
)
case 'custom':
default:
return (
<>
Allow users to use Supabase with any OpenID Connect (OIDC) provider. You can read more in
the{' '}
<a
className="hover:decoration-primary underline hover:text-foreground transition"
href={`${DOCS_URL}/guides/auth/third-party/overview`}
>
documentation
</a>
.
</>
)
}
}
export const IntegrationTypeContent = ({
type,
integration,
}: {
type: INTEGRATION_TYPES
integration: ThirdPartyAuthIntegration
}) => {
switch (type) {
case 'firebase': {
const projectName =
integration.oidc_issuer_url?.replace('https://securetoken.google.com/', '') || ''
return (
<div className="text-sm flex flex-row gap-x-4">
<span className="text-foreground-light w-36">Firebase Project ID</span>
<span className="text-foreground">{projectName}</span>
</div>
)
}
case 'auth0':
const domainName =
integration.oidc_issuer_url?.replace('https://', '').replace('.auth0.com', '') || ''
return (
<div className="text-sm flex flex-row gap-x-4">
<span className="text-foreground-light w-36">Auth0 Domain Name</span>
<span className="text-foreground">{domainName}</span>
</div>
)
case 'awsCognito': {
const region =
integration.oidc_issuer_url?.split('.').filter((s) => AWS_IDP_REGIONS.includes(s))[0] || ''
const userPoolId =
integration.oidc_issuer_url?.split('/').filter((s) => s.startsWith(region || ''))[0] || ''
return (
<div className="text-sm flex flex-col gap-y-2">
<div className="flex flex-row gap-x-4">
<span className="text-foreground-light w-36">Region</span>
<span className="text-foreground">{region}</span>
</div>
<div className="flex flex-row gap-x-4">
<span className="text-foreground-light w-36">User Pool ID</span>
<span className="text-foreground">{userPoolId}</span>
</div>
</div>
)
}
case 'clerk':
return (
<div className="text-sm flex flex-row gap-x-4">
<span className="text-foreground-light w-36">Domain</span>
<span className="text-foreground">{integration?.oidc_issuer_url ?? ''}</span>
</div>
)
case 'workos':
return (
<div className="text-sm flex flex-row gap-x-4">
<span className="text-foreground-light w-36">Issuer URL</span>
<span className="text-foreground">{integration?.oidc_issuer_url ?? ''}</span>
</div>
)
case 'custom':
default:
return (
<div className="text-sm flex flex-row gap-x-4">
<span className="text-foreground-light w-36">Issuer URL</span>
<span className="text-foreground break-all">{integration?.oidc_issuer_url ?? ''}</span>
</div>
)
}
}
export const IntegrationCard = ({
integration,
canUpdateConfig,
onDelete,
}: IntegrationCardProps) => {
const type = getIntegrationType(integration)
return (
<>
<div className="bg-surface-100 border overflow-hidden shadow-sm px-5 py-4 flex flex-row first:rounded-t-md last:rounded-b-md space-x-4">
<div className="py-1">
<Image src={getIntegrationTypeIcon(type)} width={21} height={21} alt={`${type} icon`} />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-y-4 overflow-y-auto">
<div className="text-sm flex flex-col">
<span className="text-foreground">{getIntegrationTypeLabel(type)}</span>
<div className="text-foreground-lighter">{getIntegrationTypeDescription(type)}</div>
</div>
<IntegrationTypeContent type={type} integration={integration} />
<div>
{/* TODO: this should be a configure integration where it would show the sheet and the user can disable or delete the integration
but there's no "edit integration" endpoing for now. */}
<Button variant="danger" disabled={!canUpdateConfig} onClick={() => onDelete()}>
Delete integration
</Button>
</div>
</div>
<div className="shrink-0">
{true ? (
<Badge className="space-x-1" variant="success">
<div className="h-3.5 w-3.5 bg-brand-default rounded-full flex justify-center items-center">
<CheckIcon className="h-2 w-2 text-background-overlay" strokeWidth={6} />
</div>
<span>Enabled</span>
</Badge>
) : (
<Badge variant="warning">
<span>Disabled</span>
</Badge>
)}
</div>
</div>
</>
)
}