mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-13 23:16:45 +03:00
more SSO and OIDC fixes
This commit is contained in:
@@ -13,6 +13,8 @@ import { Plus, Trash2, ExternalLink, Loader2, AlertCircle, Copy, Shield, Info }
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface SSOProvider {
|
||||
id: string;
|
||||
@@ -20,20 +22,35 @@ interface SSOProvider {
|
||||
domain: string;
|
||||
providerId: string;
|
||||
organizationId?: string;
|
||||
oidcConfig: {
|
||||
oidcConfig?: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
jwksEndpoint: string;
|
||||
userInfoEndpoint: string;
|
||||
mapping: {
|
||||
id: string;
|
||||
email: string;
|
||||
emailVerified: string;
|
||||
name: string;
|
||||
image: string;
|
||||
};
|
||||
jwksEndpoint?: string;
|
||||
userInfoEndpoint?: string;
|
||||
discoveryEndpoint?: string;
|
||||
scopes?: string[];
|
||||
pkce?: boolean;
|
||||
};
|
||||
samlConfig?: {
|
||||
entryPoint: string;
|
||||
cert: string;
|
||||
callbackUrl?: string;
|
||||
audience?: string;
|
||||
wantAssertionsSigned?: boolean;
|
||||
signatureAlgorithm?: string;
|
||||
digestAlgorithm?: string;
|
||||
identifierFormat?: string;
|
||||
};
|
||||
mapping?: {
|
||||
id: string;
|
||||
email: string;
|
||||
emailVerified?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -47,16 +64,32 @@ export function SSOSettings() {
|
||||
const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false);
|
||||
|
||||
// Form states for new provider
|
||||
const [providerType, setProviderType] = useState<'oidc' | 'saml'>('oidc');
|
||||
const [providerForm, setProviderForm] = useState({
|
||||
// Common fields
|
||||
issuer: '',
|
||||
domain: '',
|
||||
providerId: '',
|
||||
organizationId: '',
|
||||
// OIDC fields
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
authorizationEndpoint: '',
|
||||
tokenEndpoint: '',
|
||||
jwksEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
discoveryEndpoint: '',
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
pkce: true,
|
||||
// SAML fields
|
||||
entryPoint: '',
|
||||
cert: '',
|
||||
callbackUrl: '',
|
||||
audience: '',
|
||||
wantAssertionsSigned: true,
|
||||
signatureAlgorithm: 'sha256',
|
||||
digestAlgorithm: 'sha256',
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +102,7 @@ export function SSOSettings() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [providersRes, headerAuthStatus] = await Promise.all([
|
||||
apiRequest<SSOProvider[]>('/sso/providers'),
|
||||
apiRequest<SSOProvider[]>('/auth/sso/register'),
|
||||
apiRequest<{ enabled: boolean }>('/auth/header-status').catch(() => ({ enabled: false }))
|
||||
]);
|
||||
|
||||
@@ -101,6 +134,7 @@ export function SSOSettings() {
|
||||
tokenEndpoint: discovered.tokenEndpoint || '',
|
||||
jwksEndpoint: discovered.jwksEndpoint || '',
|
||||
userInfoEndpoint: discovered.userInfoEndpoint || '',
|
||||
discoveryEndpoint: discovered.discoveryEndpoint || `${providerForm.issuer}/.well-known/openid-configuration`,
|
||||
domain: discovered.suggestedDomain || prev.domain,
|
||||
}));
|
||||
|
||||
@@ -114,18 +148,38 @@ export function SSOSettings() {
|
||||
|
||||
const createProvider = async () => {
|
||||
try {
|
||||
const newProvider = await apiRequest<SSOProvider>('/sso/providers', {
|
||||
const requestData: any = {
|
||||
providerId: providerForm.providerId,
|
||||
issuer: providerForm.issuer,
|
||||
domain: providerForm.domain,
|
||||
organizationId: providerForm.organizationId || undefined,
|
||||
providerType,
|
||||
};
|
||||
|
||||
if (providerType === 'oidc') {
|
||||
requestData.clientId = providerForm.clientId;
|
||||
requestData.clientSecret = providerForm.clientSecret;
|
||||
requestData.authorizationEndpoint = providerForm.authorizationEndpoint;
|
||||
requestData.tokenEndpoint = providerForm.tokenEndpoint;
|
||||
requestData.jwksEndpoint = providerForm.jwksEndpoint;
|
||||
requestData.userInfoEndpoint = providerForm.userInfoEndpoint;
|
||||
requestData.discoveryEndpoint = providerForm.discoveryEndpoint;
|
||||
requestData.scopes = providerForm.scopes;
|
||||
requestData.pkce = providerForm.pkce;
|
||||
} else {
|
||||
requestData.entryPoint = providerForm.entryPoint;
|
||||
requestData.cert = providerForm.cert;
|
||||
requestData.callbackUrl = providerForm.callbackUrl || `${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`;
|
||||
requestData.audience = providerForm.audience || window.location.origin;
|
||||
requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned;
|
||||
requestData.signatureAlgorithm = providerForm.signatureAlgorithm;
|
||||
requestData.digestAlgorithm = providerForm.digestAlgorithm;
|
||||
requestData.identifierFormat = providerForm.identifierFormat;
|
||||
}
|
||||
|
||||
const newProvider = await apiRequest<SSOProvider>('/auth/sso/register', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
...providerForm,
|
||||
mapping: {
|
||||
id: 'sub',
|
||||
email: 'email',
|
||||
emailVerified: 'email_verified',
|
||||
name: 'name',
|
||||
image: 'picture',
|
||||
},
|
||||
},
|
||||
data: requestData,
|
||||
});
|
||||
|
||||
setProviders([...providers, newProvider]);
|
||||
@@ -134,12 +188,24 @@ export function SSOSettings() {
|
||||
issuer: '',
|
||||
domain: '',
|
||||
providerId: '',
|
||||
organizationId: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
authorizationEndpoint: '',
|
||||
tokenEndpoint: '',
|
||||
jwksEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
discoveryEndpoint: '',
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
pkce: true,
|
||||
entryPoint: '',
|
||||
cert: '',
|
||||
callbackUrl: '',
|
||||
audience: '',
|
||||
wantAssertionsSigned: true,
|
||||
signatureAlgorithm: 'sha256',
|
||||
digestAlgorithm: 'sha256',
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
});
|
||||
toast.success('SSO provider created successfully');
|
||||
} catch (error) {
|
||||
@@ -264,97 +330,171 @@ export function SSOSettings() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add SSO Provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure an external OIDC provider for user authentication
|
||||
Configure an external identity provider for user authentication
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer">Issuer URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="issuer"
|
||||
value={providerForm.issuer}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, issuer: e.target.value }))}
|
||||
placeholder="https://accounts.google.com"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={discoverOIDC}
|
||||
disabled={isDiscovering}
|
||||
>
|
||||
{isDiscovering ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Discover'}
|
||||
</Button>
|
||||
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="oidc">OIDC / OAuth2</TabsTrigger>
|
||||
<TabsTrigger value="saml">SAML 2.0</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Common Fields */}
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="providerId">Provider ID</Label>
|
||||
<Input
|
||||
id="providerId"
|
||||
value={providerForm.providerId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))}
|
||||
placeholder="google-sso"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="domain">Email Domain</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
value={providerForm.domain}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, domain: e.target.value }))}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="domain">Domain</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
value={providerForm.domain}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, domain: e.target.value }))}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
<Label htmlFor="issuer">Issuer URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="issuer"
|
||||
value={providerForm.issuer}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, issuer: e.target.value }))}
|
||||
placeholder={providerType === 'oidc' ? "https://accounts.google.com" : "https://idp.example.com"}
|
||||
/>
|
||||
{providerType === 'oidc' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={discoverOIDC}
|
||||
disabled={isDiscovering}
|
||||
>
|
||||
{isDiscovering ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Discover'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="providerId">Provider ID</Label>
|
||||
<Label htmlFor="organizationId">Organization ID (Optional)</Label>
|
||||
<Input
|
||||
id="providerId"
|
||||
value={providerForm.providerId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))}
|
||||
placeholder="google-sso"
|
||||
id="organizationId"
|
||||
value={providerForm.organizationId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, organizationId: e.target.value }))}
|
||||
placeholder="org_123"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Link this provider to an organization for automatic user provisioning</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<TabsContent value="oidc" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientId">Client ID</Label>
|
||||
<Input
|
||||
id="clientId"
|
||||
value={providerForm.clientId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, clientId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientSecret">Client Secret</Label>
|
||||
<Input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
value={providerForm.clientSecret}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, clientSecret: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientId">Client ID</Label>
|
||||
<Label htmlFor="authEndpoint">Authorization Endpoint</Label>
|
||||
<Input
|
||||
id="clientId"
|
||||
value={providerForm.clientId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, clientId: e.target.value }))}
|
||||
id="authEndpoint"
|
||||
value={providerForm.authorizationEndpoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, authorizationEndpoint: e.target.value }))}
|
||||
placeholder="https://accounts.google.com/o/oauth2/auth"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientSecret">Client Secret</Label>
|
||||
<Label htmlFor="tokenEndpoint">Token Endpoint</Label>
|
||||
<Input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
value={providerForm.clientSecret}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, clientSecret: e.target.value }))}
|
||||
id="tokenEndpoint"
|
||||
value={providerForm.tokenEndpoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, tokenEndpoint: e.target.value }))}
|
||||
placeholder="https://oauth2.googleapis.com/token"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authEndpoint">Authorization Endpoint</Label>
|
||||
<Input
|
||||
id="authEndpoint"
|
||||
value={providerForm.authorizationEndpoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, authorizationEndpoint: e.target.value }))}
|
||||
placeholder="https://accounts.google.com/o/oauth2/auth"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="pkce"
|
||||
checked={providerForm.pkce}
|
||||
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, pkce: checked }))}
|
||||
/>
|
||||
<Label htmlFor="pkce">Enable PKCE</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tokenEndpoint">Token Endpoint</Label>
|
||||
<Input
|
||||
id="tokenEndpoint"
|
||||
value={providerForm.tokenEndpoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, tokenEndpoint: e.target.value }))}
|
||||
placeholder="https://oauth2.googleapis.com/token"
|
||||
/>
|
||||
</div>
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
<TabsContent value="saml" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entryPoint">SAML Entry Point</Label>
|
||||
<Input
|
||||
id="entryPoint"
|
||||
value={providerForm.entryPoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, entryPoint: e.target.value }))}
|
||||
placeholder="https://idp.example.com/sso"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cert">X.509 Certificate</Label>
|
||||
<Textarea
|
||||
id="cert"
|
||||
value={providerForm.cert}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, cert: e.target.value }))}
|
||||
placeholder="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="wantAssertionsSigned"
|
||||
checked={providerForm.wantAssertionsSigned}
|
||||
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, wantAssertionsSigned: checked }))}
|
||||
/>
|
||||
<Label htmlFor="wantAssertionsSigned">Require Signed Assertions</Label>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
<p>Callback URL: {window.location.origin}/api/auth/sso/saml2/callback/{providerForm.providerId || '{provider-id}'}</p>
|
||||
<p>SP Metadata: {window.location.origin}/api/auth/sso/saml2/sp/metadata?providerId={providerForm.providerId || '{provider-id}'}</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowProviderDialog(false)}>
|
||||
Cancel
|
||||
@@ -391,7 +531,12 @@ export function SSOSettings() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold">{provider.providerId}</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold">{provider.providerId}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{provider.samlConfig ? 'SAML' : 'OIDC'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{provider.domain}</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -407,12 +552,26 @@ export function SSOSettings() {
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">Issuer</p>
|
||||
<p className="text-muted-foreground">{provider.issuer}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Client ID</p>
|
||||
<p className="text-muted-foreground font-mono">{provider.oidcConfig.clientId}</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>
|
||||
|
||||
@@ -36,7 +36,7 @@ export function useAuthMethods() {
|
||||
const loadAuthMethods = async () => {
|
||||
try {
|
||||
// Check SSO providers
|
||||
const providers = await apiRequest<any[]>('/sso/providers').catch(() => []);
|
||||
const providers = await apiRequest<any[]>('/auth/sso/register').catch(() => []);
|
||||
const applications = await apiRequest<any[]>('/sso/applications').catch(() => []);
|
||||
|
||||
setAuthMethods({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { oidcClient } from "better-auth/client/plugins";
|
||||
import { ssoClient } from "better-auth/client/plugins";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { oidcProvider } from "better-auth/plugins";
|
||||
import { sso } from "better-auth/plugins/sso";
|
||||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
|
||||
// This function will be called with the actual database instance
|
||||
export function createAuth(db: BunSQLiteDatabase) {
|
||||
return betterAuth({
|
||||
// Database configuration
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
usePlural: true, // Our tables use plural names (users, not user)
|
||||
}),
|
||||
|
||||
// Base URL configuration
|
||||
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
|
||||
|
||||
// Authentication methods
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false, // We'll enable this later
|
||||
sendResetPassword: async ({ user, url, token }, request) => {
|
||||
// TODO: Implement email sending for password reset
|
||||
console.log("Password reset requested for:", user.email);
|
||||
console.log("Reset URL:", url);
|
||||
},
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
cookieName: "better-auth-session",
|
||||
updateSessionCookieAge: true,
|
||||
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
||||
},
|
||||
|
||||
// User configuration
|
||||
user: {
|
||||
additionalFields: {
|
||||
// We can add custom fields here if needed
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins for OIDC/SSO support
|
||||
plugins: [
|
||||
// SSO plugin for OIDC client support
|
||||
sso({
|
||||
provisionUser: async (data) => {
|
||||
// Custom user provisioning logic for SSO users
|
||||
console.log("Provisioning SSO user:", data);
|
||||
return data;
|
||||
},
|
||||
}),
|
||||
|
||||
// OIDC Provider plugin (for future use when we want to be an OIDC provider)
|
||||
oidcProvider({
|
||||
loginPage: "/signin",
|
||||
consentPage: "/oauth/consent",
|
||||
metadata: {
|
||||
issuer: process.env.BETTER_AUTH_URL || "http://localhost:3000",
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
// Trusted origins for CORS
|
||||
trustedOrigins: [
|
||||
process.env.BETTER_AUTH_URL || "http://localhost:3000",
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* Example OIDC/SSO Configuration for Better Auth
|
||||
*
|
||||
* This file demonstrates how to enable OIDC and SSO features in Gitea Mirror.
|
||||
* To use: Copy this file to auth-oidc-config.ts and update the auth.ts import.
|
||||
*/
|
||||
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { sso } from "better-auth/plugins/sso";
|
||||
import { oidcProvider } from "better-auth/plugins/oidc";
|
||||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
|
||||
export function createAuthWithOIDC(db: BunSQLiteDatabase) {
|
||||
return betterAuth({
|
||||
// Database configuration
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
usePlural: true,
|
||||
}),
|
||||
|
||||
// Base configuration
|
||||
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
|
||||
basePath: "/api/auth",
|
||||
|
||||
// Email/Password authentication
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false,
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
cookieName: "better-auth-session",
|
||||
updateSessionCookieAge: true,
|
||||
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
||||
},
|
||||
|
||||
// User configuration with additional fields
|
||||
user: {
|
||||
additionalFields: {
|
||||
username: {
|
||||
type: "string",
|
||||
required: true,
|
||||
defaultValue: "user",
|
||||
input: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// OAuth2 providers (examples)
|
||||
socialProviders: {
|
||||
github: {
|
||||
enabled: !!process.env.GITHUB_OAUTH_CLIENT_ID,
|
||||
clientId: process.env.GITHUB_OAUTH_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET!,
|
||||
},
|
||||
google: {
|
||||
enabled: !!process.env.GOOGLE_OAUTH_CLIENT_ID,
|
||||
clientId: process.env.GOOGLE_OAUTH_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!,
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins
|
||||
plugins: [
|
||||
// SSO Plugin - For OIDC/SAML client functionality
|
||||
sso({
|
||||
// Auto-provision users from SSO providers
|
||||
provisionUser: async (data) => {
|
||||
console.log("Provisioning SSO user:", data.email);
|
||||
|
||||
// Custom logic to set username from email
|
||||
const username = data.email.split('@')[0];
|
||||
|
||||
return {
|
||||
...data,
|
||||
username,
|
||||
};
|
||||
},
|
||||
|
||||
// Organization provisioning for enterprise SSO
|
||||
organizationProvisioning: {
|
||||
disabled: false,
|
||||
defaultRole: "member",
|
||||
getRole: async (user) => {
|
||||
// Custom logic to determine user role
|
||||
// For admin emails, grant admin role
|
||||
if (user.email?.endsWith('@admin.example.com')) {
|
||||
return 'admin';
|
||||
}
|
||||
return 'member';
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// OIDC Provider Plugin - Makes Gitea Mirror an OIDC provider
|
||||
oidcProvider({
|
||||
// Login page for OIDC authentication flow
|
||||
loginPage: "/login",
|
||||
|
||||
// Consent page for OAuth2 authorization
|
||||
consentPage: "/oauth/consent",
|
||||
|
||||
// Allow dynamic client registration
|
||||
allowDynamicClientRegistration: false,
|
||||
|
||||
// OIDC metadata configuration
|
||||
metadata: {
|
||||
issuer: process.env.BETTER_AUTH_URL || "http://localhost:3000",
|
||||
authorization_endpoint: "/api/auth/oauth2/authorize",
|
||||
token_endpoint: "/api/auth/oauth2/token",
|
||||
userinfo_endpoint: "/api/auth/oauth2/userinfo",
|
||||
jwks_uri: "/api/auth/jwks",
|
||||
},
|
||||
|
||||
// Additional user info claims
|
||||
getAdditionalUserInfoClaim: (user, scopes) => {
|
||||
const claims: Record<string, any> = {};
|
||||
|
||||
// Add custom claims based on scopes
|
||||
if (scopes.includes('profile')) {
|
||||
claims.username = user.username;
|
||||
claims.preferred_username = user.username;
|
||||
}
|
||||
|
||||
if (scopes.includes('gitea')) {
|
||||
// Add Gitea-specific claims
|
||||
claims.gitea_admin = false; // Customize based on your logic
|
||||
claims.gitea_repos = []; // Could fetch user's repositories
|
||||
}
|
||||
|
||||
return claims;
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
// Trusted origins for CORS
|
||||
trustedOrigins: [
|
||||
process.env.BETTER_AUTH_URL || "http://localhost:3000",
|
||||
// Add your OIDC client domains here
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Environment variables needed:
|
||||
/*
|
||||
# OAuth2 Providers (optional)
|
||||
GITHUB_OAUTH_CLIENT_ID=your-github-client-id
|
||||
GITHUB_OAUTH_CLIENT_SECRET=your-github-client-secret
|
||||
GOOGLE_OAUTH_CLIENT_ID=your-google-client-id
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=your-google-client-secret
|
||||
|
||||
# SSO Configuration (when registering providers)
|
||||
SSO_PROVIDER_ISSUER=https://idp.example.com
|
||||
SSO_PROVIDER_CLIENT_ID=your-client-id
|
||||
SSO_PROVIDER_CLIENT_SECRET=your-client-secret
|
||||
*/
|
||||
|
||||
// Example: Registering an SSO provider programmatically
|
||||
/*
|
||||
import { authClient } from "./auth-client";
|
||||
|
||||
// Register corporate SSO
|
||||
await authClient.sso.register({
|
||||
issuer: "https://login.microsoftonline.com/tenant-id/v2.0",
|
||||
domain: "company.com",
|
||||
clientId: process.env.AZURE_CLIENT_ID!,
|
||||
clientSecret: process.env.AZURE_CLIENT_SECRET!,
|
||||
providerId: "azure-ad",
|
||||
mapping: {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
emailVerified: "email_verified",
|
||||
name: "name",
|
||||
image: "picture",
|
||||
},
|
||||
});
|
||||
*/
|
||||
@@ -1,7 +1,7 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { oidcProvider } from "better-auth/plugins";
|
||||
import { sso } from "better-auth/plugins/sso";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import { db, users } from "./db";
|
||||
import * as schema from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -25,7 +25,7 @@ export const auth = betterAuth({
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false, // We'll enable this later
|
||||
sendResetPassword: async ({ user, url, token }, request) => {
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
// TODO: Implement email sending for password reset
|
||||
console.log("Password reset requested for:", user.email);
|
||||
console.log("Reset URL:", url);
|
||||
@@ -60,6 +60,8 @@ export const auth = betterAuth({
|
||||
consentPage: "/oauth/consent",
|
||||
// Allow dynamic client registration for flexibility
|
||||
allowDynamicClientRegistration: true,
|
||||
// Note: trustedClients would be configured here if Better Auth supports it
|
||||
// For now, we'll use dynamic registration
|
||||
// Customize user info claims based on scopes
|
||||
getAdditionalUserInfoClaim: (user, scopes) => {
|
||||
const claims: Record<string, any> = {};
|
||||
@@ -73,19 +75,32 @@ export const auth = betterAuth({
|
||||
// SSO plugin - allows users to authenticate with external OIDC providers
|
||||
sso({
|
||||
// Provision new users when they sign in with SSO
|
||||
provisionUser: async (user) => {
|
||||
provisionUser: async ({ user }: { user: any, userInfo: any }) => {
|
||||
// Derive username from email if not provided
|
||||
const username = user.name || user.email?.split('@')[0] || 'user';
|
||||
return {
|
||||
...user,
|
||||
username,
|
||||
};
|
||||
|
||||
// Update user in database if needed
|
||||
await db.update(users)
|
||||
.set({ username })
|
||||
.where(eq(users.id, user.id))
|
||||
.catch(() => {}); // Ignore errors if user doesn't exist yet
|
||||
},
|
||||
// Organization provisioning settings
|
||||
organizationProvisioning: {
|
||||
disabled: false,
|
||||
defaultRole: "member",
|
||||
getRole: async ({ user, userInfo }: { user: any, userInfo: any }) => {
|
||||
// Check if user has admin attribute from SSO provider
|
||||
const isAdmin = userInfo.attributes?.role === 'admin' ||
|
||||
userInfo.attributes?.groups?.includes('admins');
|
||||
|
||||
return isAdmin ? "admin" : "member";
|
||||
},
|
||||
},
|
||||
// Override user info with provider data by default
|
||||
defaultOverrideUserInfo: true,
|
||||
// Allow implicit sign up for new users
|
||||
disableImplicitSignUp: false,
|
||||
}),
|
||||
],
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Legacy Auth Routes Backup
|
||||
|
||||
These files are the original authentication routes before migrating to Better Auth.
|
||||
They are kept here as a reference during the migration process.
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- `index.ts` - Handled user session validation and getting current user
|
||||
- `login.ts` - Handled user login with email/password
|
||||
- `logout.ts` - Handled user logout and session cleanup
|
||||
- `register.ts` - Handled new user registration
|
||||
|
||||
All these endpoints are now handled by Better Auth through the catch-all route `[...all].ts`.
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, users, configs } from "@/lib/db";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
|
||||
|
||||
export const GET: APIRoute = async ({ request, cookies }) => {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const token = authHeader?.split(" ")[1] || cookies.get("token")?.value;
|
||||
|
||||
if (!token) {
|
||||
const userCountResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users);
|
||||
const userCount = userCountResult[0].count;
|
||||
|
||||
if (userCount === 0) {
|
||||
return new Response(JSON.stringify({ error: "No users found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
|
||||
|
||||
const userResult = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, decoded.id))
|
||||
.limit(1);
|
||||
|
||||
if (!userResult.length) {
|
||||
return new Response(JSON.stringify({ error: "User not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const { password, ...userWithoutPassword } = userResult[0];
|
||||
|
||||
const configResult = await db
|
||||
.select({
|
||||
scheduleConfig: configs.scheduleConfig,
|
||||
})
|
||||
.from(configs)
|
||||
.where(and(eq(configs.userId, decoded.id), eq(configs.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
const scheduleConfig = configResult[0]?.scheduleConfig;
|
||||
|
||||
const syncEnabled = scheduleConfig?.enabled ?? false;
|
||||
const syncInterval = scheduleConfig?.interval ?? 3600;
|
||||
const lastSync = scheduleConfig?.lastRun ?? null;
|
||||
const nextSync = scheduleConfig?.nextRun ?? null;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
...userWithoutPassword,
|
||||
syncEnabled,
|
||||
syncInterval,
|
||||
lastSync,
|
||||
nextSync,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: "Invalid token" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import bcrypt from "bcryptjs";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { db, users } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const { username, password } = await request.json();
|
||||
|
||||
if (!username || !password) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Username and password are required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username))
|
||||
.limit(1);
|
||||
|
||||
if (!user.length) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid username or password" }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user[0].password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid username or password" }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { password: _, ...userWithoutPassword } = user[0];
|
||||
const token = jwt.sign({ id: user[0].id }, JWT_SECRET, { expiresIn: "7d" });
|
||||
|
||||
return new Response(JSON.stringify({ token, user: userWithoutPassword }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${
|
||||
60 * 60 * 24 * 7
|
||||
}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
export const POST: APIRoute = async () => {
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": "token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import bcrypt from "bcryptjs";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { db, users } from "@/lib/db";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const { username, email, password } = await request.json();
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Username, email, and password are required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Check if username or email already exists
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(or(eq(users.username, username), eq(users.email, email)))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Username or email already exists" }),
|
||||
{
|
||||
status: 409,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Generate UUID
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
// Create user
|
||||
const newUser = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
const { password: _, ...userWithoutPassword } = newUser[0];
|
||||
const token = jwt.sign({ id: newUser[0].id }, JWT_SECRET, {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ token, user: userWithoutPassword }), {
|
||||
status: 201,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${
|
||||
60 * 60 * 24 * 7
|
||||
}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
137
src/pages/api/auth/oauth2/register.ts
Normal file
137
src/pages/api/auth/oauth2/register.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { requireAuth } from "@/lib/utils/auth-helpers";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
// POST /api/auth/oauth2/register - Register a new OAuth2 application
|
||||
export async function POST(context: APIContext) {
|
||||
try {
|
||||
const { response: authResponse } = await requireAuth(context);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
const body = await context.request.json();
|
||||
|
||||
// Extract and validate required fields
|
||||
const {
|
||||
client_name,
|
||||
redirect_uris,
|
||||
token_endpoint_auth_method = "client_secret_basic",
|
||||
grant_types = ["authorization_code"],
|
||||
response_types = ["code"],
|
||||
client_uri,
|
||||
logo_uri,
|
||||
scope = "openid profile email",
|
||||
contacts,
|
||||
tos_uri,
|
||||
policy_uri,
|
||||
jwks_uri,
|
||||
jwks,
|
||||
metadata,
|
||||
software_id,
|
||||
software_version,
|
||||
software_statement,
|
||||
} = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!client_name || !redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "invalid_request",
|
||||
error_description: "client_name and redirect_uris are required"
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use Better Auth client to register OAuth2 application
|
||||
const response = await authClient.oauth2.register({
|
||||
client_name,
|
||||
redirect_uris,
|
||||
token_endpoint_auth_method,
|
||||
grant_types,
|
||||
response_types,
|
||||
client_uri,
|
||||
logo_uri,
|
||||
scope,
|
||||
contacts,
|
||||
tos_uri,
|
||||
policy_uri,
|
||||
jwks_uri,
|
||||
jwks,
|
||||
metadata,
|
||||
software_id,
|
||||
software_version,
|
||||
software_statement,
|
||||
});
|
||||
|
||||
// Check if response is an error
|
||||
if ('error' in response && response.error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: response.error.code || "registration_error",
|
||||
error_description: response.error.message || "Failed to register application"
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// The response follows OAuth2 RFC format with snake_case
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 201,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache"
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Handle Better Auth errors
|
||||
if (error.message?.includes('already exists')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "invalid_client_metadata",
|
||||
error_description: "Client with this configuration already exists"
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "OAuth2 registration");
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/auth/oauth2/register - Get all registered OAuth2 applications
|
||||
export async function GET(context: APIContext) {
|
||||
try {
|
||||
const { response: authResponse } = await requireAuth(context);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
// TODO: Implement listing of OAuth2 applications
|
||||
// This would require querying the database directly
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
applications: [],
|
||||
message: "OAuth2 application listing not yet implemented"
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "OAuth2 application listing");
|
||||
}
|
||||
}
|
||||
163
src/pages/api/auth/sso/register.ts
Normal file
163
src/pages/api/auth/sso/register.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { requireAuth } from "@/lib/utils/auth-helpers";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
// POST /api/auth/sso/register - Register a new SSO provider using Better Auth
|
||||
export async function POST(context: APIContext) {
|
||||
try {
|
||||
const { user, response: authResponse } = await requireAuth(context);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
const body = await context.request.json();
|
||||
|
||||
// Extract configuration based on provider type
|
||||
const { providerId, issuer, domain, organizationId, providerType = "oidc" } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!providerId || !issuer || !domain) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing required fields: providerId, issuer, and domain" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let registrationBody: any = {
|
||||
providerId,
|
||||
issuer,
|
||||
domain,
|
||||
organizationId,
|
||||
};
|
||||
|
||||
if (providerType === "saml") {
|
||||
// SAML provider configuration
|
||||
const {
|
||||
entryPoint,
|
||||
cert,
|
||||
callbackUrl,
|
||||
audience,
|
||||
wantAssertionsSigned = true,
|
||||
signatureAlgorithm = "sha256",
|
||||
digestAlgorithm = "sha256",
|
||||
identifierFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
||||
idpMetadata,
|
||||
spMetadata,
|
||||
mapping = {
|
||||
id: "nameID",
|
||||
email: "email",
|
||||
name: "displayName",
|
||||
firstName: "givenName",
|
||||
lastName: "surname",
|
||||
}
|
||||
} = body;
|
||||
|
||||
registrationBody.samlConfig = {
|
||||
entryPoint,
|
||||
cert,
|
||||
callbackUrl: callbackUrl || `${context.url.origin}/api/auth/sso/saml2/callback/${providerId}`,
|
||||
audience: audience || context.url.origin,
|
||||
wantAssertionsSigned,
|
||||
signatureAlgorithm,
|
||||
digestAlgorithm,
|
||||
identifierFormat,
|
||||
idpMetadata,
|
||||
spMetadata,
|
||||
};
|
||||
registrationBody.mapping = mapping;
|
||||
} else {
|
||||
// OIDC provider configuration
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
jwksEndpoint,
|
||||
discoveryEndpoint,
|
||||
userInfoEndpoint,
|
||||
scopes = ["openid", "email", "profile"],
|
||||
pkce = true,
|
||||
mapping = {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
emailVerified: "email_verified",
|
||||
name: "name",
|
||||
image: "picture",
|
||||
}
|
||||
} = body;
|
||||
|
||||
registrationBody.oidcConfig = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
jwksEndpoint,
|
||||
discoveryEndpoint,
|
||||
userInfoEndpoint,
|
||||
scopes,
|
||||
pkce,
|
||||
};
|
||||
registrationBody.mapping = mapping;
|
||||
}
|
||||
|
||||
// Get the user's auth headers to make the request
|
||||
const headers = new Headers();
|
||||
const cookieHeader = context.request.headers.get("cookie");
|
||||
if (cookieHeader) {
|
||||
headers.set("cookie", cookieHeader);
|
||||
}
|
||||
|
||||
// Register the SSO provider using Better Auth's API
|
||||
const response = await auth.api.registerSSOProvider({
|
||||
body: registrationBody,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Failed to register SSO provider: ${error}` }),
|
||||
{
|
||||
status: response.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "SSO registration");
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/auth/sso/register - Get all registered SSO providers
|
||||
export async function GET(context: APIContext) {
|
||||
try {
|
||||
const { user, response: authResponse } = await requireAuth(context);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
// For now, we'll need to query the database directly since Better Auth
|
||||
// doesn't provide a built-in API to list SSO providers
|
||||
// This will be implemented once we update the database schema
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: "SSO provider listing not yet implemented",
|
||||
providers: []
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "SSO provider listing");
|
||||
}
|
||||
}
|
||||
64
src/pages/api/auth/sso/sp-metadata.ts
Normal file
64
src/pages/api/auth/sso/sp-metadata.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
// GET /api/auth/sso/sp-metadata - Get Service Provider metadata for SAML
|
||||
export async function GET(context: APIContext) {
|
||||
try {
|
||||
const url = new URL(context.request.url);
|
||||
const providerId = url.searchParams.get("providerId");
|
||||
const format = url.searchParams.get("format") || "xml";
|
||||
|
||||
if (!providerId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Provider ID is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get SP metadata using Better Auth's API
|
||||
const response = await auth.api.spMetadata({
|
||||
query: {
|
||||
providerId,
|
||||
format,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Failed to get SP metadata: ${error}` }),
|
||||
{
|
||||
status: response.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Return the metadata in the requested format
|
||||
if (format === "xml") {
|
||||
const metadataXML = await response.text();
|
||||
return new Response(metadataXML, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/samlmetadata+xml",
|
||||
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const metadataJSON = await response.json();
|
||||
return new Response(JSON.stringify(metadataJSON), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "SP metadata");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user