6ac9679fb5
* feat(auth): base login fields on GoTrue providers Signed-off-by: Goostaf <gasplund2@gmail.com> # Conflicts: # app/(auth)/login/login-client.tsx # app/(auth)/register/page.tsx * fix: address feedback Signed-off-by: Goostaf <gasplund2@gmail.com> * chore: remove hardcoded Google enabled checks Signed-off-by: Goostaf <gasplund2@gmail.com> # Conflicts: # .env.example * feat: show label when password login is disabled Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: use MicrosoftMark, correct comment Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: display custom providers Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: show when no methods are available Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: display custom provider labels Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: add SAML login path Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: show when no methods are available Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: display SAML button when enabled Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: preserve nextPath and broken key Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: redirect test to client Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: expose registerEnabled Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: provider allowlist and request timeout Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: restore compact labels Signed-off-by: Goostaf <gasplund2@gmail.com> * refactor: move withTimeout implementation to utils Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: include nextPath Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: export function and test case Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: only show SAML button if vars configured Signed-off-by: Goostaf <gasplund2@gmail.com> * fix(auth): map SAML sign-in error through getErrorMessage The antipattern ratchet (check:guards, raw-user-error) rejects a raw error.message reaching a user-visible sink. Route the signInWithSSO error through getErrorMessage like the other auth error paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013BAzJjXQBa9F5L1U42wUMj Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> * feat(auth): GitHub brand mark on the provider button; decision log GitHub allows its invertocat in solid black/white, so currentColor is correct; custom OIDC providers keep the generic key icon. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013BAzJjXQBa9F5L1U42wUMj Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> --------- Signed-off-by: Goostaf <gasplund2@gmail.com> Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
207 lines
5.8 KiB
TypeScript
207 lines
5.8 KiB
TypeScript
/**
|
|
* Fetch available auth providers and capabilities from Supabase GoTrue.
|
|
*
|
|
* Calls two endpoints:
|
|
* 1. /auth/v1/settings (anon key) - returns built-in providers and signup config
|
|
* 2. auth.admin.customProviders.listProviders() (service_role key) - custom OIDC/OAuth providers
|
|
*
|
|
* Both are merged into a single provider list. If the service_role key is
|
|
* unavailable, only built-in providers are returned (custom providers are skipped).
|
|
*/
|
|
|
|
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
|
import { withTimeout } from '@/lib/utils'
|
|
|
|
export type ExternalProvider =
|
|
| 'apple'
|
|
| 'azure'
|
|
| 'bitbucket'
|
|
| 'discord'
|
|
| 'facebook'
|
|
| 'figma'
|
|
| 'fly'
|
|
| 'github'
|
|
| 'gitlab'
|
|
| 'google'
|
|
| 'kakao'
|
|
| 'keycloak'
|
|
| 'linkedin'
|
|
| 'linkedin_oidc'
|
|
| 'notion'
|
|
| 'slack'
|
|
| 'slack_oidc'
|
|
| 'snapchat'
|
|
| 'spotify'
|
|
| 'twitch'
|
|
| 'twitter'
|
|
| 'workos'
|
|
| 'zoom'
|
|
| (string & {})
|
|
|
|
export interface GoTrueSettingsResponse {
|
|
external: Record<string, boolean>
|
|
disable_signup: boolean
|
|
mailer_autoconfirm: boolean
|
|
phone_autoconfirm: boolean
|
|
sms_provider: string
|
|
saml_enabled: boolean
|
|
passkeys_enabled: boolean
|
|
}
|
|
|
|
/**
|
|
* Display metadata for known OAuth providers.
|
|
* Unknown providers (custom OIDC) get a generic SSO label.
|
|
*/
|
|
const PROVIDER_META: Record<
|
|
string,
|
|
{ label: string }
|
|
> = {
|
|
apple: { label: 'Apple' },
|
|
azure: { label: 'Microsoft' },
|
|
bitbucket: { label: 'Bitbucket' },
|
|
discord: { label: 'Discord' },
|
|
facebook: { label: 'Facebook' },
|
|
figma: { label: 'Figma' },
|
|
fly: { label: 'Fly' },
|
|
github: { label: 'GitHub' },
|
|
gitlab: { label: 'GitLab' },
|
|
google: { label: 'Google' },
|
|
kakao: { label: 'Kakao' },
|
|
keycloak: { label: 'Keycloak' },
|
|
linkedin: { label: 'LinkedIn' },
|
|
linkedin_oidc: { label: 'LinkedIn' },
|
|
notion: { label: 'Notion' },
|
|
slack: { label: 'Slack' },
|
|
slack_oidc: { label: 'Slack' },
|
|
snapchat: { label: 'Snapchat' },
|
|
spotify: { label: 'Spotify' },
|
|
twitch: { label: 'Twitch' },
|
|
twitter: { label: 'X / Twitter' },
|
|
workos: { label: 'WorkOS' },
|
|
zoom: { label: 'Zoom' },
|
|
}
|
|
|
|
export interface ResolvedProvider {
|
|
/** Provider id passed to supabase.auth.signInWithOAuth({ provider }) */
|
|
id: string
|
|
/** Human-readable display name */
|
|
label: string
|
|
/** True for custom OIDC providers not in the built-in list */
|
|
isCustom: boolean
|
|
}
|
|
|
|
export interface GoTrueAuthSettings {
|
|
/** Enabled OAuth/OIDC providers for button rendering */
|
|
providers: ResolvedProvider[]
|
|
/** Whether email+password login is available (email provider enabled) */
|
|
passwordLoginEnabled: boolean
|
|
/** Whether self-service registration is allowed (disable_signup = false) */
|
|
registrationEnabled: boolean
|
|
/** Whether SAML SSO is enabled */
|
|
samlEnabled: boolean
|
|
}
|
|
|
|
/**
|
|
* Allowlist of auth-js Provider identifiers that may appear as OAuth/OIDC
|
|
* buttons. GoTrue's /auth/v1/settings `external` map can include entries
|
|
* that are not login providers (e.g. `anonymous_users` in the sandbox
|
|
* project). Only entries in this set are forwarded to the UI.
|
|
*
|
|
* Custom OIDC providers (prefixed `custom:`) are merged separately via
|
|
* the admin endpoint and do not go through this filter.
|
|
*/
|
|
const ALLOWED_EXTERNAL_PROVIDERS = new Set<ExternalProvider>([
|
|
'apple',
|
|
'azure',
|
|
'bitbucket',
|
|
'discord',
|
|
'facebook',
|
|
'figma',
|
|
'fly',
|
|
'github',
|
|
'gitlab',
|
|
'google',
|
|
'kakao',
|
|
'keycloak',
|
|
'linkedin',
|
|
'linkedin_oidc',
|
|
'notion',
|
|
'slack',
|
|
'slack_oidc',
|
|
'snapchat',
|
|
'spotify',
|
|
'twitch',
|
|
'twitter',
|
|
'workos',
|
|
'zoom',
|
|
])
|
|
|
|
/**
|
|
* Fetch auth settings from GoTrue.
|
|
*
|
|
* Returns the list of enabled OAuth/OIDC providers for button rendering,
|
|
* plus whether email+password login and registration are available.
|
|
* Falls back to a safe default (no providers, password login enabled)
|
|
* on network errors so the login page still renders.
|
|
*/
|
|
export async function fetchAuthSettings(): Promise<GoTrueAuthSettings> {
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
|
|
|
if (!supabaseUrl || !anonKey) {
|
|
return { providers: [], passwordLoginEnabled: true, registrationEnabled: true, samlEnabled: false }
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`${supabaseUrl}/auth/v1/settings`, {
|
|
headers: { apikey: anonKey },
|
|
next: { revalidate: 60 }, // cache for 1 minute
|
|
signal: AbortSignal.timeout(3000),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
return { providers: [], passwordLoginEnabled: true, registrationEnabled: true, samlEnabled: false }
|
|
}
|
|
|
|
const data: GoTrueSettingsResponse = await res.json()
|
|
|
|
const providers = Object.entries(data.external)
|
|
.filter(([name, enabled]) => enabled && ALLOWED_EXTERNAL_PROVIDERS.has(name as ExternalProvider))
|
|
.map(([name]) => ({
|
|
id: name,
|
|
label: PROVIDER_META[name]?.label ?? name,
|
|
isCustom: false,
|
|
}))
|
|
|
|
if (process.env.SUPABASE_SERVICE_ROLE_KEY) {
|
|
try {
|
|
const serviceClient = createServiceClientNoCookies()
|
|
const { data: customData } = await withTimeout(
|
|
serviceClient.auth.admin.customProviders.listProviders(),
|
|
3000,
|
|
)
|
|
for (const cp of customData?.providers ?? []) {
|
|
if (cp.enabled && cp.identifier) {
|
|
providers.push({
|
|
id: cp.identifier,
|
|
label: PROVIDER_META[cp.identifier]?.label ?? cp.name ?? cp.identifier,
|
|
isCustom: !(cp.identifier in PROVIDER_META),
|
|
})
|
|
}
|
|
}
|
|
} catch {
|
|
// Custom providers are best-effort; don't break login if the admin endpoint is unreachable or slow.
|
|
}
|
|
}
|
|
|
|
return {
|
|
providers,
|
|
passwordLoginEnabled: data.external.email === true,
|
|
registrationEnabled: !data.disable_signup,
|
|
samlEnabled: data.saml_enabled,
|
|
}
|
|
} catch {
|
|
return { providers: [], passwordLoginEnabled: true, registrationEnabled: true, samlEnabled: false }
|
|
}
|
|
}
|