mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-07 12:06:46 +03:00
Compare commits
1 Commits
157-mirror
...
v3-sso
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5b4482c8a |
@@ -29,6 +29,8 @@ This guide explains how to test SSO authentication locally with Gitea Mirror.
|
|||||||
- Client Secret: (from Google Console)
|
- Client Secret: (from Google Console)
|
||||||
- Save the provider
|
- Save the provider
|
||||||
|
|
||||||
|
Note: Provider creation uses Better Auth's SSO registration under the hood. Do not call the legacy `POST /api/sso/providers` endpoint directly; it is deprecated and reserved for internal mirroring. Use the UI or Better Auth client/server registration APIs instead.
|
||||||
|
|
||||||
## Option 2: Using Keycloak (Local Identity Provider)
|
## Option 2: Using Keycloak (Local Identity Provider)
|
||||||
|
|
||||||
### Setup with Docker:
|
### Setup with Docker:
|
||||||
@@ -113,8 +115,8 @@ npm start
|
|||||||
|
|
||||||
2. **Provider not showing in login**
|
2. **Provider not showing in login**
|
||||||
- Check browser console for errors
|
- Check browser console for errors
|
||||||
- Verify provider was saved successfully
|
- Verify provider was saved successfully (via UI)
|
||||||
- Check `/api/sso/providers` returns your providers
|
- Check `/api/sso/providers` (or `/api/sso/providers/public`) returns your providers. This list mirrors what was registered with Better Auth.
|
||||||
|
|
||||||
3. **Redirect URI mismatch**
|
3. **Redirect URI mismatch**
|
||||||
- Ensure the redirect URI in your OAuth app matches exactly:
|
- Ensure the redirect URI in your OAuth app matches exactly:
|
||||||
|
|||||||
31756
docs/better-auth-docs.md
Normal file
31756
docs/better-auth-docs.md
Normal file
File diff suppressed because it is too large
Load Diff
1
drizzle/0006_illegal_spyke.sql
Normal file
1
drizzle/0006_illegal_spyke.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `sso_providers` ADD `saml_config` text;
|
||||||
1948
drizzle/meta/0006_snapshot.json
Normal file
1948
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
|||||||
"when": 1757786449446,
|
"when": 1757786449446,
|
||||||
"tag": "0005_polite_preak",
|
"tag": "0005_polite_preak",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1757825311459,
|
||||||
|
"tag": "0006_illegal_spyke",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -70,6 +70,8 @@ export function LoginForm() {
|
|||||||
domain: domain,
|
domain: domain,
|
||||||
providerId: providerId,
|
providerId: providerId,
|
||||||
callbackURL: `${baseURL}/`,
|
callbackURL: `${baseURL}/`,
|
||||||
|
errorCallbackURL: `${baseURL}/auth-error`,
|
||||||
|
newUserCallbackURL: `${baseURL}/`,
|
||||||
scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin.
|
scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin.
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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';
|
import { MultiSelect } from '@/components/ui/multi-select';
|
||||||
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
|
||||||
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
|
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -158,50 +159,146 @@ export function SSOSettings() {
|
|||||||
const createProvider = async () => {
|
const createProvider = async () => {
|
||||||
setAddingProvider(true);
|
setAddingProvider(true);
|
||||||
try {
|
try {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editingProvider) {
|
if (editingProvider) {
|
||||||
// Update existing provider
|
// Delete and recreate to align with Better Auth docs
|
||||||
const updatedProvider = await apiRequest<SSOProvider>(`/sso/providers?id=${editingProvider.id}`, {
|
try {
|
||||||
method: 'PUT',
|
await apiRequest(`/sso/providers?id=${editingProvider.id}`, { method: 'DELETE' });
|
||||||
data: requestData,
|
} catch (e) {
|
||||||
});
|
// Continue even if local delete fails; registration will mirror latest
|
||||||
setProviders(providers.map(p => p.id === editingProvider.id ? updatedProvider : p));
|
console.warn('Failed to delete local provider before recreate', e);
|
||||||
toast.success('SSO provider updated successfully');
|
}
|
||||||
|
|
||||||
|
// Recreate via Better Auth registration
|
||||||
|
try {
|
||||||
|
if (providerType === 'oidc') {
|
||||||
|
await authClient.sso.register({
|
||||||
|
providerId: providerForm.providerId,
|
||||||
|
issuer: providerForm.issuer,
|
||||||
|
domain: providerForm.domain,
|
||||||
|
organizationId: providerForm.organizationId || undefined,
|
||||||
|
oidcConfig: {
|
||||||
|
clientId: providerForm.clientId || undefined,
|
||||||
|
clientSecret: providerForm.clientSecret || undefined,
|
||||||
|
authorizationEndpoint: providerForm.authorizationEndpoint || undefined,
|
||||||
|
tokenEndpoint: providerForm.tokenEndpoint || undefined,
|
||||||
|
jwksEndpoint: providerForm.jwksEndpoint || undefined,
|
||||||
|
userInfoEndpoint: providerForm.userInfoEndpoint || undefined,
|
||||||
|
discoveryEndpoint: providerForm.discoveryEndpoint || undefined,
|
||||||
|
scopes: providerForm.scopes,
|
||||||
|
pkce: providerForm.pkce,
|
||||||
|
},
|
||||||
|
mapping: {
|
||||||
|
id: 'sub',
|
||||||
|
email: 'email',
|
||||||
|
emailVerified: 'email_verified',
|
||||||
|
name: 'name',
|
||||||
|
image: 'picture',
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
} else {
|
||||||
|
await authClient.sso.register({
|
||||||
|
providerId: providerForm.providerId,
|
||||||
|
issuer: providerForm.issuer,
|
||||||
|
domain: providerForm.domain,
|
||||||
|
organizationId: providerForm.organizationId || undefined,
|
||||||
|
samlConfig: {
|
||||||
|
entryPoint: providerForm.entryPoint,
|
||||||
|
cert: providerForm.cert,
|
||||||
|
callbackUrl:
|
||||||
|
providerForm.callbackUrl ||
|
||||||
|
`${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`,
|
||||||
|
audience: providerForm.audience || window.location.origin,
|
||||||
|
wantAssertionsSigned: providerForm.wantAssertionsSigned,
|
||||||
|
signatureAlgorithm: providerForm.signatureAlgorithm,
|
||||||
|
digestAlgorithm: providerForm.digestAlgorithm,
|
||||||
|
identifierFormat: providerForm.identifierFormat,
|
||||||
|
},
|
||||||
|
mapping: {
|
||||||
|
id: 'nameID',
|
||||||
|
email: 'email',
|
||||||
|
name: 'displayName',
|
||||||
|
firstName: 'givenName',
|
||||||
|
lastName: 'surname',
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
toast.success('SSO provider recreated');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Recreate failed', e);
|
||||||
|
const msg = typeof e?.message === 'string' ? e.message : String(e);
|
||||||
|
// Common case: providerId already exists in Better Auth
|
||||||
|
if (msg.toLowerCase().includes('already exists')) {
|
||||||
|
toast.error('Provider ID already exists in auth server. Choose a new Provider ID and try again.');
|
||||||
|
} else {
|
||||||
|
showErrorToast(e, toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh providers from our API after registration mirrors into DB
|
||||||
|
const refreshed = await apiRequest<SSOProvider[] | { providers: SSOProvider[] }>(
|
||||||
|
'/sso/providers'
|
||||||
|
);
|
||||||
|
setProviders(Array.isArray(refreshed) ? refreshed : refreshed?.providers || []);
|
||||||
} else {
|
} else {
|
||||||
// Create new provider
|
// Create new provider - follow Better Auth docs using the SSO client
|
||||||
const newProvider = await apiRequest<SSOProvider>('/sso/providers', {
|
if (providerType === 'oidc') {
|
||||||
method: 'POST',
|
await authClient.sso.register({
|
||||||
data: requestData,
|
providerId: providerForm.providerId,
|
||||||
});
|
issuer: providerForm.issuer,
|
||||||
setProviders([...providers, newProvider]);
|
domain: providerForm.domain,
|
||||||
|
organizationId: providerForm.organizationId || undefined,
|
||||||
|
oidcConfig: {
|
||||||
|
clientId: providerForm.clientId || undefined,
|
||||||
|
clientSecret: providerForm.clientSecret || undefined,
|
||||||
|
authorizationEndpoint: providerForm.authorizationEndpoint || undefined,
|
||||||
|
tokenEndpoint: providerForm.tokenEndpoint || undefined,
|
||||||
|
jwksEndpoint: providerForm.jwksEndpoint || undefined,
|
||||||
|
userInfoEndpoint: providerForm.userInfoEndpoint || undefined,
|
||||||
|
discoveryEndpoint: providerForm.discoveryEndpoint || undefined,
|
||||||
|
scopes: providerForm.scopes,
|
||||||
|
pkce: providerForm.pkce,
|
||||||
|
},
|
||||||
|
mapping: {
|
||||||
|
id: 'sub',
|
||||||
|
email: 'email',
|
||||||
|
emailVerified: 'email_verified',
|
||||||
|
name: 'name',
|
||||||
|
image: 'picture',
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
} else {
|
||||||
|
await authClient.sso.register({
|
||||||
|
providerId: providerForm.providerId,
|
||||||
|
issuer: providerForm.issuer,
|
||||||
|
domain: providerForm.domain,
|
||||||
|
organizationId: providerForm.organizationId || undefined,
|
||||||
|
samlConfig: {
|
||||||
|
entryPoint: providerForm.entryPoint,
|
||||||
|
cert: providerForm.cert,
|
||||||
|
callbackUrl:
|
||||||
|
providerForm.callbackUrl ||
|
||||||
|
`${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`,
|
||||||
|
audience: providerForm.audience || window.location.origin,
|
||||||
|
wantAssertionsSigned: providerForm.wantAssertionsSigned,
|
||||||
|
signatureAlgorithm: providerForm.signatureAlgorithm,
|
||||||
|
digestAlgorithm: providerForm.digestAlgorithm,
|
||||||
|
identifierFormat: providerForm.identifierFormat,
|
||||||
|
},
|
||||||
|
mapping: {
|
||||||
|
id: 'nameID',
|
||||||
|
email: 'email',
|
||||||
|
name: 'displayName',
|
||||||
|
firstName: 'givenName',
|
||||||
|
lastName: 'surname',
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh providers from our API after registration mirrors into DB
|
||||||
|
const refreshed = await apiRequest<SSOProvider[] | { providers: SSOProvider[] }>(
|
||||||
|
'/sso/providers'
|
||||||
|
);
|
||||||
|
setProviders(Array.isArray(refreshed) ? refreshed : refreshed?.providers || []);
|
||||||
toast.success('SSO provider created successfully');
|
toast.success('SSO provider created successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -617,6 +617,7 @@ export const ssoProviders = sqliteTable("sso_providers", {
|
|||||||
issuer: text("issuer").notNull(),
|
issuer: text("issuer").notNull(),
|
||||||
domain: text("domain").notNull(),
|
domain: text("domain").notNull(),
|
||||||
oidcConfig: text("oidc_config").notNull(), // JSON string with OIDC configuration
|
oidcConfig: text("oidc_config").notNull(), // JSON string with OIDC configuration
|
||||||
|
samlConfig: text("saml_config"), // JSON string with SAML configuration (optional)
|
||||||
userId: text("user_id").notNull(), // Admin who created this provider
|
userId: text("user_id").notNull(), // Admin who created this provider
|
||||||
providerId: text("provider_id").notNull().unique(), // Unique identifier for the provider
|
providerId: text("provider_id").notNull().unique(), // Unique identifier for the provider
|
||||||
organizationId: text("organization_id"), // Optional - if provider is linked to an organization
|
organizationId: text("organization_id"), // Optional - if provider is linked to an organization
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import type { APIContext } from "astro";
|
|||||||
import { createSecureErrorResponse } from "@/lib/utils";
|
import { createSecureErrorResponse } from "@/lib/utils";
|
||||||
import { requireAuth } from "@/lib/utils/auth-helpers";
|
import { requireAuth } from "@/lib/utils/auth-helpers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { db, ssoProviders } from "@/lib/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
// POST /api/auth/sso/register - Register a new SSO provider using Better Auth
|
// POST /api/auth/sso/register - Register a new SSO provider using Better Auth
|
||||||
export async function POST(context: APIContext) {
|
export async function POST(context: APIContext) {
|
||||||
@@ -169,6 +172,46 @@ export async function POST(context: APIContext) {
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Mirror provider into our local sso_providers table for UI listing
|
||||||
|
try {
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(ssoProviders)
|
||||||
|
.where(eq(ssoProviders.providerId, providerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const values: any = {
|
||||||
|
issuer: registrationBody.issuer,
|
||||||
|
domain: registrationBody.domain,
|
||||||
|
organizationId: registrationBody.organizationId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (registrationBody.oidcConfig) {
|
||||||
|
values.oidcConfig = JSON.stringify(registrationBody.oidcConfig);
|
||||||
|
}
|
||||||
|
if (registrationBody.samlConfig) {
|
||||||
|
values.samlConfig = JSON.stringify(registrationBody.samlConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db.update(ssoProviders).set(values).where(eq(ssoProviders.id, existing[0].id));
|
||||||
|
} else {
|
||||||
|
await db.insert(ssoProviders).values({
|
||||||
|
id: nanoid(),
|
||||||
|
issuer: registrationBody.issuer,
|
||||||
|
domain: registrationBody.domain,
|
||||||
|
oidcConfig: JSON.stringify(registrationBody.oidcConfig || {}),
|
||||||
|
samlConfig: registrationBody.samlConfig ? JSON.stringify(registrationBody.samlConfig) : undefined,
|
||||||
|
userId: user.id,
|
||||||
|
providerId: registrationBody.providerId,
|
||||||
|
organizationId: registrationBody.organizationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Do not fail the main request if mirroring to local table fails
|
||||||
|
console.warn("Failed to mirror SSO provider to local DB:", e);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify(result), {
|
return new Response(JSON.stringify(result), {
|
||||||
status: 201,
|
status: 201,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { requireAuth } from "@/lib/utils/auth-helpers";
|
|||||||
import { db, ssoProviders } from "@/lib/db";
|
import { db, ssoProviders } from "@/lib/db";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
// GET /api/sso/providers - List all SSO providers
|
// GET /api/sso/providers - List all SSO providers
|
||||||
export async function GET(context: APIContext) {
|
export async function GET(context: APIContext) {
|
||||||
@@ -29,7 +30,11 @@ export async function GET(context: APIContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/sso/providers - Create a new SSO provider
|
// POST /api/sso/providers - DEPRECATED legacy create (use Better Auth registration)
|
||||||
|
// This route remains for backward-compatibility only. Preferred flow:
|
||||||
|
// - Client/UI calls authClient.sso.register(...) to register with Better Auth
|
||||||
|
// - Server mirrors provider into local DB for listing
|
||||||
|
// Creation via this route is discouraged and may be removed in a future version.
|
||||||
export async function POST(context: APIContext) {
|
export async function POST(context: APIContext) {
|
||||||
try {
|
try {
|
||||||
const { user, response } = await requireAuth(context);
|
const { user, response } = await requireAuth(context);
|
||||||
@@ -45,10 +50,12 @@ export async function POST(context: APIContext) {
|
|||||||
tokenEndpoint,
|
tokenEndpoint,
|
||||||
jwksEndpoint,
|
jwksEndpoint,
|
||||||
userInfoEndpoint,
|
userInfoEndpoint,
|
||||||
|
discoveryEndpoint,
|
||||||
mapping,
|
mapping,
|
||||||
providerId,
|
providerId,
|
||||||
organizationId,
|
organizationId,
|
||||||
scopes,
|
scopes,
|
||||||
|
pkce,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -62,6 +69,32 @@ export async function POST(context: APIContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean issuer URL (remove trailing slash); validate URL format
|
||||||
|
let cleanIssuer = issuer;
|
||||||
|
try {
|
||||||
|
const issuerUrl = new URL(issuer.toString().trim());
|
||||||
|
cleanIssuer = issuerUrl.toString().replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate OIDC endpoints: require discoveryEndpoint or at least authorization+token
|
||||||
|
const hasDiscovery = typeof discoveryEndpoint === 'string' && discoveryEndpoint.trim() !== '';
|
||||||
|
const hasCoreEndpoints = typeof authorizationEndpoint === 'string' && authorizationEndpoint.trim() !== ''
|
||||||
|
&& typeof tokenEndpoint === 'string' && tokenEndpoint.trim() !== '';
|
||||||
|
if (!hasDiscovery && !hasCoreEndpoints) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Invalid OIDC configuration",
|
||||||
|
details: "Provide discoveryEndpoint, or both authorizationEndpoint and tokenEndpoint."
|
||||||
|
}),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if provider ID already exists
|
// Check if provider ID already exists
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -79,15 +112,27 @@ export async function POST(context: APIContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create OIDC config object
|
// Helper to validate and normalize URL strings (optional fields allowed)
|
||||||
|
const validateUrl = (value?: string) => {
|
||||||
|
if (!value || typeof value !== 'string' || value.trim() === '') return undefined;
|
||||||
|
try {
|
||||||
|
return new URL(value.trim()).toString();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create OIDC config object (store as-is for UI and for Better Auth registration)
|
||||||
const oidcConfig = {
|
const oidcConfig = {
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
authorizationEndpoint,
|
authorizationEndpoint: validateUrl(authorizationEndpoint),
|
||||||
tokenEndpoint,
|
tokenEndpoint: validateUrl(tokenEndpoint),
|
||||||
jwksEndpoint,
|
jwksEndpoint: validateUrl(jwksEndpoint),
|
||||||
userInfoEndpoint,
|
userInfoEndpoint: validateUrl(userInfoEndpoint),
|
||||||
|
discoveryEndpoint: validateUrl(discoveryEndpoint),
|
||||||
scopes: scopes || ["openid", "email", "profile"],
|
scopes: scopes || ["openid", "email", "profile"],
|
||||||
|
pkce: pkce !== false,
|
||||||
mapping: mapping || {
|
mapping: mapping || {
|
||||||
id: "sub",
|
id: "sub",
|
||||||
email: "email",
|
email: "email",
|
||||||
@@ -97,12 +142,55 @@ export async function POST(context: APIContext) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// First, register with Better Auth so the SSO plugin has the provider
|
||||||
|
try {
|
||||||
|
const headers = new Headers();
|
||||||
|
const cookieHeader = context.request.headers.get("cookie");
|
||||||
|
if (cookieHeader) headers.set("cookie", cookieHeader);
|
||||||
|
|
||||||
|
const res = await auth.api.registerSSOProvider({
|
||||||
|
body: {
|
||||||
|
providerId,
|
||||||
|
issuer: cleanIssuer,
|
||||||
|
domain,
|
||||||
|
organizationId,
|
||||||
|
oidcConfig: {
|
||||||
|
clientId: oidcConfig.clientId,
|
||||||
|
clientSecret: oidcConfig.clientSecret,
|
||||||
|
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
||||||
|
tokenEndpoint: oidcConfig.tokenEndpoint,
|
||||||
|
jwksEndpoint: oidcConfig.jwksEndpoint,
|
||||||
|
discoveryEndpoint: oidcConfig.discoveryEndpoint,
|
||||||
|
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
||||||
|
scopes: oidcConfig.scopes,
|
||||||
|
pkce: oidcConfig.pkce,
|
||||||
|
},
|
||||||
|
mapping: oidcConfig.mapping,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text();
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Failed to register SSO provider: ${errText}` }),
|
||||||
|
{ status: res.status || 500, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Better Auth registration failed: ${message}` }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Insert new provider
|
// Insert new provider
|
||||||
const [newProvider] = await db
|
const [newProvider] = await db
|
||||||
.insert(ssoProviders)
|
.insert(ssoProviders)
|
||||||
.values({
|
.values({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
issuer,
|
issuer: cleanIssuer,
|
||||||
domain,
|
domain,
|
||||||
oidcConfig: JSON.stringify(oidcConfig),
|
oidcConfig: JSON.stringify(oidcConfig),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ import MainLayout from '../../layouts/main.astro';
|
|||||||
{ var: 'PORT', desc: 'Server port', default: '4321' },
|
{ var: 'PORT', desc: 'Server port', default: '4321' },
|
||||||
{ var: 'HOST', desc: 'Server host', default: '0.0.0.0' },
|
{ var: 'HOST', desc: 'Server host', default: '0.0.0.0' },
|
||||||
{ var: 'BETTER_AUTH_SECRET', desc: 'Authentication secret key', default: 'Auto-generated' },
|
{ var: 'BETTER_AUTH_SECRET', desc: 'Authentication secret key', default: 'Auto-generated' },
|
||||||
{ var: 'BETTER_AUTH_URL', desc: 'Authentication base URL', default: 'http://localhost:4321' },
|
{ var: 'BETTER_AUTH_URL', desc: 'Authentication base URL (public origin)', default: 'http://localhost:4321' },
|
||||||
|
{ var: 'PUBLIC_BETTER_AUTH_URL', desc: 'Optional: public URL used by the client', default: 'Unset' },
|
||||||
|
{ var: 'BETTER_AUTH_TRUSTED_ORIGINS', desc: 'Comma-separated list of additional trusted origins', default: 'Unset' },
|
||||||
{ var: 'NODE_EXTRA_CA_CERTS', desc: 'Path to CA certificate file', default: 'None' },
|
{ var: 'NODE_EXTRA_CA_CERTS', desc: 'Path to CA certificate file', default: 'None' },
|
||||||
{ var: 'DATABASE_URL', desc: 'SQLite database path', default: './data/gitea-mirror.db' },
|
{ var: 'DATABASE_URL', desc: 'SQLite database path', default: './data/gitea-mirror.db' },
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ import MainLayout from '../../layouts/main.astro';
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{[
|
{[
|
||||||
'User accounts and authentication data (Better Auth)',
|
'User accounts and authentication data (Better Auth)',
|
||||||
'OAuth applications and SSO provider configurations',
|
'OAuth applications and SSO provider configurations (providers registered via Better Auth; mirrored locally for UI)',
|
||||||
'GitHub and Gitea configuration',
|
'GitHub and Gitea configuration',
|
||||||
'Repository and organization information',
|
'Repository and organization information',
|
||||||
'Mirroring job history and status',
|
'Mirroring job history and status',
|
||||||
|
|||||||
@@ -107,6 +107,23 @@ import MainLayout from '../../layouts/main.astro';
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-4">Adding an SSO Provider</h3>
|
<h3 class="text-xl font-semibold mb-4">Adding an SSO Provider</h3>
|
||||||
|
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4 mb-6">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="text-blue-600 dark:text-blue-500">
|
||||||
|
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-blue-600 dark:text-blue-500 mb-1">Better Auth Registration</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
Provider creation uses Better Auth's SSO registration under the hood. The legacy API route
|
||||||
|
<code class="bg-muted px-1 rounded">POST /api/sso/providers</code> is deprecated and not required for setup.
|
||||||
|
To change Provider ID or endpoints, delete and recreate the provider from the UI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bg-card rounded-lg border border-border p-6 mb-6">
|
<div class="bg-card rounded-lg border border-border p-6 mb-6">
|
||||||
<h4 class="font-semibold mb-4">Required Information</h4>
|
<h4 class="font-semibold mb-4">Required Information</h4>
|
||||||
@@ -147,11 +164,11 @@ import MainLayout from '../../layouts/main.astro';
|
|||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-4">Redirect URL Configuration</h3>
|
<h3 class="text-xl font-semibold mb-4">Redirect URL Configuration</h3>
|
||||||
|
|
||||||
<div class="bg-muted/30 rounded-lg p-4">
|
<div class="bg-muted/30 rounded-lg p-4">
|
||||||
<p class="text-sm mb-2">When configuring your SSO provider, use this redirect URL:</p>
|
<p class="text-sm mb-2">When configuring your SSO provider, use this redirect URL:</p>
|
||||||
<code class="bg-muted rounded px-3 py-2 block">https://your-domain.com/api/auth/sso/callback/{`{provider-id}`}</code>
|
<code class="bg-muted rounded px-3 py-2 block">https://your-domain.com/api/auth/sso/callback/{`{provider-id}`}</code>
|
||||||
<p class="text-xs text-muted-foreground mt-2">Replace <code>{`{provider-id}`}</code> with your chosen Provider ID (e.g., google-sso)</p>
|
<p class="text-xs text-muted-foreground mt-2">Replace <code>{`{provider-id}`}</code> with your chosen Provider ID (e.g., google-sso)</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="my-12 h-px bg-border/50"></div>
|
<div class="my-12 h-px bg-border/50"></div>
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import MainLayout from '../../layouts/main.astro';
|
|||||||
const envVars = [
|
const envVars = [
|
||||||
{ name: 'NODE_ENV', desc: 'Runtime environment', default: 'development', example: 'production' },
|
{ name: 'NODE_ENV', desc: 'Runtime environment', default: 'development', example: 'production' },
|
||||||
{ name: 'DATABASE_URL', desc: 'SQLite database URL', default: 'file:data/gitea-mirror.db', example: 'file:path/to/database.db' },
|
{ name: 'DATABASE_URL', desc: 'SQLite database URL', default: 'file:data/gitea-mirror.db', example: 'file:path/to/database.db' },
|
||||||
{ name: 'JWT_SECRET', desc: 'Secret key for JWT auth', default: 'Auto-generated', example: 'your-secure-string' },
|
{ name: 'BETTER_AUTH_SECRET', desc: 'Authentication secret key', default: 'Auto-generated', example: 'generate a strong random string' },
|
||||||
|
{ name: 'BETTER_AUTH_URL', desc: 'Authentication base URL (public origin)', default: 'http://localhost:4321', example: 'https://gitea-mirror.example.com' },
|
||||||
|
{ name: 'PUBLIC_BETTER_AUTH_URL', desc: 'Optional: public URL used by the client', default: 'Unset', example: 'https://gitea-mirror.example.com' },
|
||||||
|
{ name: 'BETTER_AUTH_TRUSTED_ORIGINS', desc: 'Comma-separated list of additional trusted origins', default: 'Unset', example: 'https://gitea-mirror.example.com,https://alt.example.com' },
|
||||||
{ name: 'HOST', desc: 'Server host', default: 'localhost', example: '0.0.0.0' },
|
{ name: 'HOST', desc: 'Server host', default: 'localhost', example: '0.0.0.0' },
|
||||||
{ name: 'PORT', desc: 'Server port', default: '4321', example: '8080' }
|
{ name: 'PORT', desc: 'Server port', default: '4321', example: '8080' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -260,6 +260,16 @@ bun run start</code></pre>
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
num: '3',
|
num: '3',
|
||||||
|
title: 'Optional: Configure SSO',
|
||||||
|
items: [
|
||||||
|
'Open Configuration → Authentication',
|
||||||
|
'Click “Add Provider” and enter your OIDC details',
|
||||||
|
'Use redirect URL: https://<your-domain>/api/auth/sso/callback/{provider-id}',
|
||||||
|
'Edits are handled as delete & recreate (Better Auth registration)'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: '4',
|
||||||
title: 'Configure Gitea Connection',
|
title: 'Configure Gitea Connection',
|
||||||
items: [
|
items: [
|
||||||
'Enter your Gitea server URL',
|
'Enter your Gitea server URL',
|
||||||
@@ -269,7 +279,7 @@ bun run start</code></pre>
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
num: '4',
|
num: '5',
|
||||||
title: 'Set Up Scheduling',
|
title: 'Set Up Scheduling',
|
||||||
items: [
|
items: [
|
||||||
'Enable automatic mirroring',
|
'Enable automatic mirroring',
|
||||||
|
|||||||
Reference in New Issue
Block a user