more SSO and OIDC fixes

This commit is contained in:
Arunavo Ray
2025-07-21 12:09:38 +05:30
parent 0244133e7b
commit d4aa665873
16 changed files with 836 additions and 598 deletions

View File

@@ -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>

View File

@@ -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({

View File

@@ -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({

View File

@@ -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",
],
});
}

View File

@@ -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",
},
});
*/

View File

@@ -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,
}),
],

View File

@@ -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`.

View File

@@ -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" },
});
}
};

View File

@@ -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
}`,
},
});
};

View File

@@ -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",
},
});
};

View File

@@ -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
}`,
},
});
};

View 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");
}
}

View 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");
}
}

View 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");
}
}