feat(auth): base available login methods off GoTrue providers (#1869)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
dc07ca8872
commit
6ac9679fb5
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { swedishToday, formatCurrency } from '../utils'
|
||||
import { swedishToday, formatCurrency, withTimeout } from '../utils'
|
||||
|
||||
describe('swedishToday', () => {
|
||||
it('formats the date as ISO yyyy-MM-dd with a Swedish weekday', () => {
|
||||
@@ -43,3 +43,20 @@ describe('formatCurrency', () => {
|
||||
expect(formatCurrency(10, 'EUR')).toContain('€')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withTimeout', () => {
|
||||
it('resolves with the promise value when it settles in time', async () => {
|
||||
const result = await withTimeout(Promise.resolve('ok'), 1000)
|
||||
expect(result).toBe('ok')
|
||||
})
|
||||
|
||||
it('rejects when the promise exceeds the deadline', async () => {
|
||||
const slow = new Promise<string>((resolve) => setTimeout(() => resolve('late'), 200))
|
||||
await expect(withTimeout(slow, 50)).rejects.toThrow('Timeout after 50ms')
|
||||
})
|
||||
|
||||
it('rejects when the promise itself rejects', async () => {
|
||||
const failing = Promise.reject(new Error('boom'))
|
||||
await expect(withTimeout(failing, 1000)).rejects.toThrow('boom')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fetchAuthSettings, type GoTrueSettingsResponse } from '@/lib/auth/gotrue-providers'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', () => ({
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockListProviders = vi.fn()
|
||||
|
||||
vi.mocked(createServiceClientNoCookies).mockReturnValue({
|
||||
auth: {
|
||||
admin: {
|
||||
customProviders: {
|
||||
listProviders: mockListProviders,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
function fakeSettings(overrides: Partial<GoTrueSettingsResponse> = {}): GoTrueSettingsResponse {
|
||||
return {
|
||||
external: {},
|
||||
disable_signup: false,
|
||||
mailer_autoconfirm: true,
|
||||
phone_autoconfirm: true,
|
||||
sms_provider: 'twilio',
|
||||
saml_enabled: false,
|
||||
passkeys_enabled: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mockFetch(body: GoTrueSettingsResponse, status = 200) {
|
||||
return vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(body), { status }),
|
||||
)
|
||||
}
|
||||
|
||||
describe('fetchAuthSettings', () => {
|
||||
const savedUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const savedKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
const savedServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
if (savedUrl !== undefined) process.env.NEXT_PUBLIC_SUPABASE_URL = savedUrl
|
||||
else delete process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
if (savedKey !== undefined) process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = savedKey
|
||||
else delete process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
if (savedServiceKey !== undefined) process.env.SUPABASE_SERVICE_ROLE_KEY = savedServiceKey
|
||||
else delete process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
mockListProviders.mockReset()
|
||||
})
|
||||
|
||||
it('returns empty providers and defaults when env vars are missing', async () => {
|
||||
delete process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
delete process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
const spy = vi.spyOn(global, 'fetch')
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result).toEqual({
|
||||
providers: [],
|
||||
passwordLoginEnabled: true,
|
||||
registrationEnabled: true,
|
||||
samlEnabled: false,
|
||||
})
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns safe defaults on non-200 response', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings(), 500)
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result).toEqual({
|
||||
providers: [],
|
||||
passwordLoginEnabled: true,
|
||||
registrationEnabled: true,
|
||||
samlEnabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns safe defaults on fetch error', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('network'))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result).toEqual({
|
||||
providers: [],
|
||||
passwordLoginEnabled: true,
|
||||
registrationEnabled: true,
|
||||
samlEnabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('calls GoTrue settings endpoint with apikey header', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
const spy = mockFetch(fakeSettings())
|
||||
await fetchAuthSettings()
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'https://project.supabase.co/auth/v1/settings',
|
||||
expect.objectContaining({
|
||||
headers: { apikey: 'anon-key-123' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns passwordLoginEnabled=true when email is enabled', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings({ external: { email: true } }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.passwordLoginEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('returns passwordLoginEnabled=false when email is disabled', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings({ external: { email: false } }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.passwordLoginEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('returns registrationEnabled=true when disable_signup is false', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings({ disable_signup: false }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.registrationEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('returns registrationEnabled=false when disable_signup is true', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings({ disable_signup: true }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.registrationEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves known providers with brand labels', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings({ external: { google: true, github: true, email: true } }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.providers).toEqual([
|
||||
{ id: 'google', label: 'Google', isCustom: false },
|
||||
{ id: 'github', label: 'GitHub', isCustom: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes unknown external providers not in the allowlist', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings({ external: { 'my-oidc': true } }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.providers).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes disabled providers', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings({ external: { google: false, github: true } }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.providers).toEqual([
|
||||
{ id: 'github', label: 'GitHub', isCustom: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes the email provider from the provider list', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings({ external: { email: true, google: true } }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.providers).toHaveLength(1)
|
||||
expect(result.providers[0].id).toBe('google')
|
||||
})
|
||||
|
||||
it('excludes the phone provider from the provider list', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings({ external: { phone: true, google: true } }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.providers).toHaveLength(1)
|
||||
expect(result.providers[0].id).toBe('google')
|
||||
})
|
||||
|
||||
it('excludes non-provider entries like anonymous_users', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings({ external: { anonymous_users: true, google: true, email: true } }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.providers).toHaveLength(1)
|
||||
expect(result.providers[0].id).toBe('google')
|
||||
})
|
||||
|
||||
it('returns empty providers when no external providers are enabled', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings({ external: { email: true } }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.providers).toEqual([])
|
||||
})
|
||||
|
||||
it('merges custom providers from the admin endpoint', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role-key'
|
||||
|
||||
mockFetch(fakeSettings({ external: { google: true } }))
|
||||
mockListProviders.mockResolvedValue({
|
||||
data: {
|
||||
providers: [
|
||||
{ identifier: 'custom:mycompany', name: 'My Company SSO', enabled: true },
|
||||
{ identifier: 'custom:other', name: 'Other', enabled: false },
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.providers).toEqual([
|
||||
{ id: 'google', label: 'Google', isCustom: false },
|
||||
{ id: 'custom:mycompany', label: 'My Company SSO', isCustom: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to built-in providers when custom endpoint throws', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role-key'
|
||||
|
||||
mockFetch(fakeSettings({ external: { github: true } }))
|
||||
mockListProviders.mockRejectedValue(new Error('network'))
|
||||
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.providers).toEqual([{ id: 'github', label: 'GitHub', isCustom: false }])
|
||||
})
|
||||
|
||||
it('skips custom providers when service_role key is missing', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
delete process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
const spy = mockFetch(fakeSettings({ external: { google: true } }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.providers).toEqual([{ id: 'google', label: 'Google', isCustom: false }])
|
||||
// Only one fetch call (settings), no admin call
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns samlEnabled=true when SAML is enabled', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings({ saml_enabled: true }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.samlEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('returns samlEnabled=false when SAML is disabled', async () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
|
||||
mockFetch(fakeSettings({ saml_enabled: false }))
|
||||
const result = await fetchAuthSettings()
|
||||
expect(result.samlEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -67,7 +67,7 @@ describe('Turnstile rollout state', () => {
|
||||
describe('Turnstile integration contract', () => {
|
||||
it('protects every public Supabase Auth flow in scope', () => {
|
||||
const login = readRepoFile('app/(auth)/login/login-client.tsx')
|
||||
const register = readRepoFile('app/(auth)/register/page.tsx')
|
||||
const register = readRepoFile('app/(auth)/register/register-client.tsx')
|
||||
const sandbox = readRepoFile('app/sandbox/page.tsx')
|
||||
|
||||
expect(login).toMatch(
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Google OAuth feature flag.
|
||||
*
|
||||
* Signing in with Google requires the Google provider to be configured in
|
||||
* Supabase (GoTrue) with a Google Cloud OAuth client; the flag ships the UI
|
||||
* dark until that is done:
|
||||
* https://supabase.com/docs/guides/auth/social-login/auth-google
|
||||
* Unlike BankID this is not hosted-only: self-hosted installations can
|
||||
* configure their own Google OAuth client.
|
||||
*/
|
||||
import { flagEnabled } from '@/lib/env/public-flags'
|
||||
|
||||
export function isGoogleAuthEnabled(): boolean {
|
||||
return flagEnabled(process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
}
|
||||
@@ -198,3 +198,23 @@ export function generateInvoiceNumber(): string {
|
||||
export function isValidExchangeRate(rate: number | null | undefined): rate is number {
|
||||
return rate != null && rate > 0 && rate < 100000
|
||||
}
|
||||
|
||||
// Run a promise against a wall-clock budget. The underlying work continues to
|
||||
// completion on the server when the budget elapses: we just stop waiting for
|
||||
// it. For Anthropic calls that's fine: a slow Opus turn finishing later still
|
||||
// warms its own cache.
|
||||
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
|
||||
promise.then(
|
||||
(v) => {
|
||||
clearTimeout(timer)
|
||||
resolve(v)
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(timer)
|
||||
reject(e)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user