This commit is contained in:
Arunavo Ray
2025-07-26 22:06:29 +05:30
parent 0920314679
commit 5f45a9a03d
7 changed files with 459 additions and 86 deletions

View File

@@ -55,7 +55,7 @@ export function LoginForm() {
} }
} }
async function handleSSOLogin(domain?: string) { async function handleSSOLogin(domain?: string, providerId?: string) {
setIsLoading(true); setIsLoading(true);
try { try {
if (!domain && !ssoEmail) { if (!domain && !ssoEmail) {
@@ -66,6 +66,7 @@ export function LoginForm() {
await authClient.signIn.sso({ await authClient.signIn.sso({
email: ssoEmail || undefined, email: ssoEmail || undefined,
domain: domain, domain: domain,
providerId: providerId,
callbackURL: '/', callbackURL: '/',
}); });
} catch (error) { } catch (error) {
@@ -175,7 +176,7 @@ export function LoginForm() {
key={provider.id} key={provider.id}
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={() => handleSSOLogin(provider.domain)} onClick={() => handleSSOLogin(provider.domain, provider.providerId)}
disabled={isLoading} disabled={isLoading}
> >
<Globe className="h-4 w-4 mr-2" /> <Globe className="h-4 w-4 mr-2" />
@@ -217,7 +218,7 @@ export function LoginForm() {
<CardFooter> <CardFooter>
<Button <Button
className="w-full" className="w-full"
onClick={() => handleSSOLogin()} onClick={() => handleSSOLogin(undefined, undefined)}
disabled={isLoading || !ssoEmail} disabled={isLoading || !ssoEmail}
> >
{isLoading ? 'Redirecting...' : 'Continue with SSO'} {isLoading ? 'Redirecting...' : 'Continue with SSO'}

View File

@@ -8,11 +8,12 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { apiRequest, showErrorToast } from '@/lib/utils'; import { apiRequest, showErrorToast } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Plus, Trash2, Loader2, AlertCircle, Shield } from 'lucide-react'; import { Plus, Trash2, Loader2, AlertCircle, Shield, Edit2 } from 'lucide-react';
import { Skeleton } from '../ui/skeleton'; import { Skeleton } from '../ui/skeleton';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { MultiSelect } from '@/components/ui/multi-select';
interface SSOProvider { interface SSOProvider {
id: string; id: string;
@@ -58,8 +59,10 @@ export function SSOSettings() {
const [providers, setProviders] = useState<SSOProvider[]>([]); const [providers, setProviders] = useState<SSOProvider[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [showProviderDialog, setShowProviderDialog] = useState(false); const [showProviderDialog, setShowProviderDialog] = useState(false);
const [addingProvider, setAddingProvider] = useState(false);
const [isDiscovering, setIsDiscovering] = useState(false); const [isDiscovering, setIsDiscovering] = useState(false);
const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false); const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false);
const [editingProvider, setEditingProvider] = useState<SSOProvider | null>(null);
// Form states for new provider // Form states for new provider
const [providerType, setProviderType] = useState<'oidc' | 'saml'>('oidc'); const [providerType, setProviderType] = useState<'oidc' | 'saml'>('oidc');
@@ -77,7 +80,7 @@ export function SSOSettings() {
jwksEndpoint: '', jwksEndpoint: '',
userInfoEndpoint: '', userInfoEndpoint: '',
discoveryEndpoint: '', discoveryEndpoint: '',
scopes: ['openid', 'email', 'profile'], scopes: ['openid', 'email', 'profile'] as string[],
pkce: true, pkce: true,
// SAML fields // SAML fields
entryPoint: '', entryPoint: '',
@@ -145,6 +148,7 @@ export function SSOSettings() {
}; };
const createProvider = async () => { const createProvider = async () => {
setAddingProvider(true);
try { try {
const requestData: any = { const requestData: any = {
providerId: providerForm.providerId, providerId: providerForm.providerId,
@@ -162,7 +166,7 @@ export function SSOSettings() {
requestData.jwksEndpoint = providerForm.jwksEndpoint; requestData.jwksEndpoint = providerForm.jwksEndpoint;
requestData.userInfoEndpoint = providerForm.userInfoEndpoint; requestData.userInfoEndpoint = providerForm.userInfoEndpoint;
requestData.discoveryEndpoint = providerForm.discoveryEndpoint; requestData.discoveryEndpoint = providerForm.discoveryEndpoint;
// Don't send scopes - let the backend handle provider-specific defaults requestData.scopes = providerForm.scopes;
requestData.pkce = providerForm.pkce; requestData.pkce = providerForm.pkce;
} else { } else {
requestData.entryPoint = providerForm.entryPoint; requestData.entryPoint = providerForm.entryPoint;
@@ -175,13 +179,26 @@ export function SSOSettings() {
requestData.identifierFormat = providerForm.identifierFormat; requestData.identifierFormat = providerForm.identifierFormat;
} }
const newProvider = await apiRequest<SSOProvider>('/sso/providers', { if (editingProvider) {
method: 'POST', // Update existing provider
data: requestData, const updatedProvider = await apiRequest<SSOProvider>(`/sso/providers?id=${editingProvider.id}`, {
}); method: 'PUT',
data: requestData,
});
setProviders(providers.map(p => p.id === editingProvider.id ? updatedProvider : p));
toast.success('SSO provider updated successfully');
} else {
// Create new provider
const newProvider = await apiRequest<SSOProvider>('/sso/providers', {
method: 'POST',
data: requestData,
});
setProviders([...providers, newProvider]);
toast.success('SSO provider created successfully');
}
setProviders([...providers, newProvider]);
setShowProviderDialog(false); setShowProviderDialog(false);
setEditingProvider(null);
setProviderForm({ setProviderForm({
issuer: '', issuer: '',
domain: '', domain: '',
@@ -194,7 +211,7 @@ export function SSOSettings() {
jwksEndpoint: '', jwksEndpoint: '',
userInfoEndpoint: '', userInfoEndpoint: '',
discoveryEndpoint: '', discoveryEndpoint: '',
scopes: ['openid', 'email', 'profile'], scopes: ['openid', 'email', 'profile'] as string[],
pkce: true, pkce: true,
entryPoint: '', entryPoint: '',
cert: '', cert: '',
@@ -205,12 +222,39 @@ export function SSOSettings() {
digestAlgorithm: 'sha256', digestAlgorithm: 'sha256',
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
}); });
toast.success('SSO provider created successfully');
} catch (error) { } catch (error) {
showErrorToast(error, toast); showErrorToast(error, toast);
} finally {
setAddingProvider(false);
} }
}; };
const startEditProvider = (provider: SSOProvider) => {
setEditingProvider(provider);
setProviderType(provider.samlConfig ? 'saml' : 'oidc');
if (provider.oidcConfig) {
setProviderForm({
...providerForm,
providerId: provider.providerId,
issuer: provider.issuer,
domain: provider.domain,
organizationId: provider.organizationId || '',
clientId: provider.oidcConfig.clientId || '',
clientSecret: provider.oidcConfig.clientSecret || '',
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint || '',
tokenEndpoint: provider.oidcConfig.tokenEndpoint || '',
jwksEndpoint: provider.oidcConfig.jwksEndpoint || '',
userInfoEndpoint: provider.oidcConfig.userInfoEndpoint || '',
discoveryEndpoint: provider.oidcConfig.discoveryEndpoint || '',
scopes: provider.oidcConfig.scopes || ['openid', 'email', 'profile'],
pkce: provider.oidcConfig.pkce !== false,
});
}
setShowProviderDialog(true);
};
const deleteProvider = async (id: string) => { const deleteProvider = async (id: string) => {
try { try {
await apiRequest(`/sso/providers?id=${id}`, { method: 'DELETE' }); await apiRequest(`/sso/providers?id=${id}`, { method: 'DELETE' });
@@ -237,8 +281,8 @@ export function SSOSettings() {
{/* Header with status indicators */} {/* Header with status indicators */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-lg font-semibold">Authentication & SSO</h3> <h2 className="text-2xl font-semibold">Authentication & SSO</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground mt-1">
Configure how users authenticate with your application Configure how users authenticate with your application
</p> </p>
</div> </div>
@@ -251,9 +295,9 @@ export function SSOSettings() {
</div> </div>
{/* Authentication Methods Overview */} {/* Authentication Methods Overview */}
<Card className="mb-6"> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Active Authentication Methods</CardTitle> <CardTitle className="text-lg font-semibold">Active Authentication Methods</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
@@ -308,8 +352,8 @@ export function SSOSettings() {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle>External Identity Providers</CardTitle> <CardTitle className="text-lg font-semibold">External Identity Providers</CardTitle>
<CardDescription> <CardDescription className="text-sm">
Connect external OIDC/OAuth providers (Google, Azure AD, etc.) to allow users to sign in with their existing accounts Connect external OIDC/OAuth providers (Google, Azure AD, etc.) to allow users to sign in with their existing accounts
</CardDescription> </CardDescription>
</div> </div>
@@ -322,9 +366,11 @@ export function SSOSettings() {
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Add SSO Provider</DialogTitle> <DialogTitle>{editingProvider ? 'Edit SSO Provider' : 'Add SSO Provider'}</DialogTitle>
<DialogDescription> <DialogDescription>
Configure an external identity provider for user authentication {editingProvider
? 'Update the configuration for this identity provider'
: 'Configure an external identity provider for user authentication'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}> <Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
@@ -343,6 +389,7 @@ export function SSOSettings() {
value={providerForm.providerId} value={providerForm.providerId}
onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))} onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))}
placeholder="google-sso" placeholder="google-sso"
disabled={!!editingProvider}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -430,6 +477,24 @@ export function SSOSettings() {
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="scopes">OAuth Scopes</Label>
<MultiSelect
options={[
{ label: "OpenID", value: "openid" },
{ label: "Email", value: "email" },
{ label: "Profile", value: "profile" },
{ label: "Offline Access", value: "offline_access" },
]}
selected={providerForm.scopes}
onChange={(scopes) => setProviderForm(prev => ({ ...prev, scopes }))}
placeholder="Select scopes..."
/>
<p className="text-xs text-muted-foreground">
Select the OAuth scopes to request from the provider
</p>
</div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
id="pkce" id="pkce"
@@ -446,7 +511,7 @@ export function SSOSettings() {
<p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p> <p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p>
{providerForm.issuer.includes('google.com') && ( {providerForm.issuer.includes('google.com') && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Note: Google doesn't support the "offline_access" scope. The system will automatically use appropriate scopes. Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes.
</p> </p>
)} )}
</div> </div>
@@ -497,10 +562,49 @@ export function SSOSettings() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setShowProviderDialog(false)}> <Button
variant="outline"
onClick={() => {
setShowProviderDialog(false);
setEditingProvider(null);
// Reset form
setProviderForm({
issuer: '',
domain: '',
providerId: '',
organizationId: '',
clientId: '',
clientSecret: '',
authorizationEndpoint: '',
tokenEndpoint: '',
jwksEndpoint: '',
userInfoEndpoint: '',
discoveryEndpoint: '',
scopes: ['openid', 'email', 'profile'] as string[],
pkce: true,
entryPoint: '',
cert: '',
callbackUrl: '',
audience: '',
wantAssertionsSigned: true,
signatureAlgorithm: 'sha256',
digestAlgorithm: 'sha256',
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
});
}}
>
Cancel Cancel
</Button> </Button>
<Button onClick={createProvider}>Create Provider</Button> <Button onClick={createProvider} disabled={addingProvider}>
{addingProvider ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{editingProvider ? 'Updating...' : 'Creating...'}
</>
) : (
editingProvider ? 'Update Provider' : 'Create Provider'
)}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -526,56 +630,83 @@ export function SSOSettings() {
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-3">
{providers.map(provider => ( {providers.map(provider => (
<Card key={provider.id}> <div key={provider.id} className="border rounded-lg p-4 hover:bg-muted/50 transition-colors">
<CardHeader> <div className="flex items-start justify-between gap-4">
<div className="flex items-center justify-between"> <div className="flex-1 min-w-0">
<div> <div className="flex items-center gap-2 mb-2">
<div className="flex items-center gap-2"> <h4 className="font-semibold text-sm">{provider.providerId}</h4>
<h4 className="font-semibold">{provider.providerId}</h4> <Badge variant="outline" className="text-xs">
<Badge variant="outline" className="text-xs"> {provider.samlConfig ? 'SAML' : 'OIDC'}
{provider.samlConfig ? 'SAML' : 'OIDC'} </Badge>
</Badge>
</div>
<p className="text-sm text-muted-foreground">{provider.domain}</p>
</div> </div>
<p className="text-sm text-muted-foreground mb-3">{provider.domain}</p>
<div className="space-y-2">
<div className="flex items-start gap-2 text-sm">
<span className="text-muted-foreground min-w-[80px]">Issuer:</span>
<span className="text-muted-foreground break-all">{provider.issuer}</span>
</div>
{provider.oidcConfig && (
<>
<div className="flex items-start gap-2 text-sm">
<span className="text-muted-foreground min-w-[80px]">Client ID:</span>
<span className="font-mono text-xs text-muted-foreground break-all">{provider.oidcConfig.clientId}</span>
</div>
{provider.oidcConfig.scopes && provider.oidcConfig.scopes.length > 0 && (
<div className="flex items-start gap-2 text-sm">
<span className="text-muted-foreground min-w-[80px]">Scopes:</span>
<div className="flex flex-wrap gap-1">
{provider.oidcConfig.scopes.map(scope => (
<Badge key={scope} variant="secondary" className="text-xs">
{scope}
</Badge>
))}
</div>
</div>
)}
</>
)}
{provider.samlConfig && (
<div className="flex items-start gap-2 text-sm">
<span className="text-muted-foreground min-w-[80px]">Entry Point:</span>
<span className="text-muted-foreground break-all">{provider.samlConfig.entryPoint}</span>
</div>
)}
{provider.organizationId && (
<div className="flex items-start gap-2 text-sm">
<span className="text-muted-foreground min-w-[80px]">Organization:</span>
<span className="text-muted-foreground">{provider.organizationId}</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button <Button
variant="destructive" variant="ghost"
size="sm" size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={() => startEditProvider(provider)}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={() => deleteProvider(provider.id)} onClick={() => deleteProvider(provider.id)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</CardHeader> </div>
<CardContent> </div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="font-medium">Issuer</p>
<p className="text-muted-foreground break-all">{provider.issuer}</p>
</div>
{provider.oidcConfig && (
<div>
<p className="font-medium">Client ID</p>
<p className="text-muted-foreground font-mono break-all">{provider.oidcConfig.clientId}</p>
</div>
)}
{provider.samlConfig && (
<div>
<p className="font-medium">Entry Point</p>
<p className="text-muted-foreground break-all">{provider.samlConfig.entryPoint}</p>
</div>
)}
{provider.organizationId && (
<div className="col-span-2">
<p className="font-medium">Organization</p>
<p className="text-muted-foreground">{provider.organizationId}</p>
</div>
)}
</div>
</CardContent>
</Card>
))} ))}
</div> </div>
)} )}

View File

@@ -0,0 +1,137 @@
import * as React from "react"
import { X } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface MultiSelectProps {
options: { label: string; value: string }[]
selected: string[]
onChange: (selected: string[]) => void
placeholder?: string
className?: string
}
export function MultiSelect({
options,
selected,
onChange,
placeholder = "Select items...",
className,
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false)
const handleUnselect = (item: string) => {
onChange(selected.filter((i) => i !== item))
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={`w-full justify-between ${selected.length > 0 ? "h-full" : ""} ${className}`}
>
<div className="flex gap-1 flex-wrap">
{selected.length > 0 ? (
selected.map((item) => (
<Badge
variant="secondary"
key={item}
className="mr-1 mb-1"
onClick={(e) => {
e.stopPropagation()
handleUnselect(item)
}}
>
{options.find((option) => option.value === item)?.label || item}
<button
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUnselect(item)
}
}}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleUnselect(item)
}}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
))
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command className={className}>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandEmpty>No item found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
onSelect={() => {
onChange(
selected.includes(option.value)
? selected.filter((item) => item !== option.value)
: [...selected, option.value]
)
setOpen(true)
}}
>
<div
className={`mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary ${
selected.includes(option.value)
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
}`}
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span>{option.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -35,8 +35,8 @@ export function useAuthMethods() {
const loadAuthMethods = async () => { const loadAuthMethods = async () => {
try { try {
// Check SSO providers // Check SSO providers - use public endpoint since this is used on login page
const providers = await apiRequest<any[]>('/sso/providers').catch(() => []); const providers = await apiRequest<any[]>('/sso/providers/public').catch(() => []);
const applications = await apiRequest<any[]>('/sso/applications').catch(() => []); const applications = await apiRequest<any[]>('/sso/applications').catch(() => []);
setAuthMethods({ setAuthMethods({

View File

@@ -88,22 +88,8 @@ export async function POST(context: APIContext) {
} }
} = body; } = body;
// Handle provider-specific scope defaults // Use provided scopes or default if not specified
let finalScopes = scopes; const finalScopes = scopes || ["openid", "email", "profile"];
if (!finalScopes) {
// Check if this is a Google provider
const isGoogle = issuer.includes('google.com') ||
issuer.includes('googleapis.com') ||
domain.includes('google.com');
if (isGoogle) {
// Google doesn't support offline_access scope
finalScopes = ["openid", "email", "profile"];
} else {
// Default scopes for other providers
finalScopes = ["openid", "email", "profile", "offline_access"];
}
}
registrationBody.oidcConfig = { registrationBody.oidcConfig = {
clientId, clientId,

View File

@@ -17,7 +17,7 @@ export async function GET(context: APIContext) {
const formattedProviders = providers.map(provider => ({ const formattedProviders = providers.map(provider => ({
...provider, ...provider,
oidcConfig: provider.oidcConfig ? JSON.parse(provider.oidcConfig) : undefined, oidcConfig: provider.oidcConfig ? JSON.parse(provider.oidcConfig) : undefined,
samlConfig: provider.samlConfig ? JSON.parse(provider.samlConfig) : undefined, samlConfig: (provider as any).samlConfig ? JSON.parse((provider as any).samlConfig) : undefined,
})); }));
return new Response(JSON.stringify(formattedProviders), { return new Response(JSON.stringify(formattedProviders), {
@@ -48,6 +48,7 @@ export async function POST(context: APIContext) {
mapping, mapping,
providerId, providerId,
organizationId, organizationId,
scopes,
} = body; } = body;
// Validate required fields // Validate required fields
@@ -86,6 +87,7 @@ export async function POST(context: APIContext) {
tokenEndpoint, tokenEndpoint,
jwksEndpoint, jwksEndpoint,
userInfoEndpoint, userInfoEndpoint,
scopes: scopes || ["openid", "email", "profile"],
mapping: mapping || { mapping: mapping || {
id: "sub", id: "sub",
email: "email", email: "email",
@@ -113,7 +115,7 @@ export async function POST(context: APIContext) {
const formattedProvider = { const formattedProvider = {
...newProvider, ...newProvider,
oidcConfig: newProvider.oidcConfig ? JSON.parse(newProvider.oidcConfig) : undefined, oidcConfig: newProvider.oidcConfig ? JSON.parse(newProvider.oidcConfig) : undefined,
samlConfig: newProvider.samlConfig ? JSON.parse(newProvider.samlConfig) : undefined, samlConfig: (newProvider as any).samlConfig ? JSON.parse((newProvider as any).samlConfig) : undefined,
}; };
return new Response(JSON.stringify(formattedProvider), { return new Response(JSON.stringify(formattedProvider), {
@@ -125,6 +127,100 @@ export async function POST(context: APIContext) {
} }
} }
// PUT /api/sso/providers - Update an existing SSO provider
export async function PUT(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const url = new URL(context.request.url);
const providerId = url.searchParams.get("id");
if (!providerId) {
return new Response(
JSON.stringify({ error: "Provider ID is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const body = await context.request.json();
const {
issuer,
domain,
clientId,
clientSecret,
authorizationEndpoint,
tokenEndpoint,
jwksEndpoint,
userInfoEndpoint,
scopes,
organizationId,
} = body;
// Get existing provider
const [existingProvider] = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.id, providerId))
.limit(1);
if (!existingProvider) {
return new Response(
JSON.stringify({ error: "Provider not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
);
}
// Parse existing config
const existingConfig = JSON.parse(existingProvider.oidcConfig);
// Create updated OIDC config
const updatedOidcConfig = {
...existingConfig,
clientId: clientId || existingConfig.clientId,
clientSecret: clientSecret || existingConfig.clientSecret,
authorizationEndpoint: authorizationEndpoint || existingConfig.authorizationEndpoint,
tokenEndpoint: tokenEndpoint || existingConfig.tokenEndpoint,
jwksEndpoint: jwksEndpoint || existingConfig.jwksEndpoint,
userInfoEndpoint: userInfoEndpoint || existingConfig.userInfoEndpoint,
scopes: scopes || existingConfig.scopes || ["openid", "email", "profile"],
};
// Update provider
const [updatedProvider] = await db
.update(ssoProviders)
.set({
issuer: issuer || existingProvider.issuer,
domain: domain || existingProvider.domain,
oidcConfig: JSON.stringify(updatedOidcConfig),
organizationId: organizationId !== undefined ? organizationId : existingProvider.organizationId,
updatedAt: new Date(),
})
.where(eq(ssoProviders.id, providerId))
.returning();
// Parse JSON fields before sending
const formattedProvider = {
...updatedProvider,
oidcConfig: JSON.parse(updatedProvider.oidcConfig),
samlConfig: (updatedProvider as any).samlConfig ? JSON.parse((updatedProvider as any).samlConfig) : undefined,
};
return new Response(JSON.stringify(formattedProvider), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO providers API");
}
}
// DELETE /api/sso/providers - Delete a provider by ID // DELETE /api/sso/providers - Delete a provider by ID
export async function DELETE(context: APIContext) { export async function DELETE(context: APIContext) {
try { try {

View File

@@ -0,0 +1,22 @@
import type { APIContext } from "astro";
import { createSecureErrorResponse } from "@/lib/utils";
import { db, ssoProviders } from "@/lib/db";
// GET /api/sso/providers/public - Get public SSO provider information for login page
export async function GET(context: APIContext) {
try {
// Get all providers but only return public information
const providers = await db.select({
id: ssoProviders.id,
providerId: ssoProviders.providerId,
domain: ssoProviders.domain,
}).from(ssoProviders);
return new Response(JSON.stringify(providers), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "Public SSO providers API");
}
}