Added SSO and OIDC

This commit is contained in:
Arunavo Ray
2025-07-11 01:04:50 +05:30
parent 7cb414c7cb
commit fad78516ef
26 changed files with 5598 additions and 244 deletions

View File

@@ -0,0 +1,176 @@
import type { APIContext } from "astro";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
import { db, oauthApplications } from "@/lib/db";
import { nanoid } from "nanoid";
import { eq } from "drizzle-orm";
import { generateRandomString } from "@/lib/utils";
// GET /api/sso/applications - List all OAuth applications
export async function GET(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const applications = await db.select().from(oauthApplications);
// Don't send client secrets in list response
const sanitizedApps = applications.map(app => ({
...app,
clientSecret: undefined,
}));
return new Response(JSON.stringify(sanitizedApps), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO applications API");
}
}
// POST /api/sso/applications - Create a new OAuth application
export async function POST(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const body = await context.request.json();
const { name, redirectURLs, type = "web", metadata } = body;
// Validate required fields
if (!name || !redirectURLs || redirectURLs.length === 0) {
return new Response(
JSON.stringify({ error: "Name and at least one redirect URL are required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
// Generate client credentials
const clientId = `client_${generateRandomString(32)}`;
const clientSecret = `secret_${generateRandomString(48)}`;
// Insert new application
const [newApp] = await db
.insert(oauthApplications)
.values({
id: nanoid(),
clientId,
clientSecret,
name,
redirectURLs: Array.isArray(redirectURLs) ? redirectURLs.join(",") : redirectURLs,
type,
metadata: metadata ? JSON.stringify(metadata) : null,
userId: user.id,
disabled: false,
})
.returning();
return new Response(JSON.stringify(newApp), {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO applications API");
}
}
// PUT /api/sso/applications/:id - Update an OAuth application
export async function PUT(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const url = new URL(context.request.url);
const appId = url.pathname.split("/").pop();
if (!appId) {
return new Response(
JSON.stringify({ error: "Application ID is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const body = await context.request.json();
const { name, redirectURLs, disabled, metadata } = body;
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (redirectURLs !== undefined) {
updateData.redirectURLs = Array.isArray(redirectURLs)
? redirectURLs.join(",")
: redirectURLs;
}
if (disabled !== undefined) updateData.disabled = disabled;
if (metadata !== undefined) updateData.metadata = JSON.stringify(metadata);
const [updated] = await db
.update(oauthApplications)
.set({
...updateData,
updatedAt: new Date(),
})
.where(eq(oauthApplications.id, appId))
.returning();
if (!updated) {
return new Response(JSON.stringify({ error: "Application not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ ...updated, clientSecret: undefined }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO applications API");
}
}
// DELETE /api/sso/applications/:id - Delete an OAuth application
export async function DELETE(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const url = new URL(context.request.url);
const appId = url.searchParams.get("id");
if (!appId) {
return new Response(
JSON.stringify({ error: "Application ID is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const deleted = await db
.delete(oauthApplications)
.where(eq(oauthApplications.id, appId))
.returning();
if (deleted.length === 0) {
return new Response(JSON.stringify({ error: "Application not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO applications API");
}
}

View File

@@ -0,0 +1,69 @@
import type { APIContext } from "astro";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
// POST /api/sso/discover - Discover OIDC configuration from issuer URL
export async function POST(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const { issuer } = await context.request.json();
if (!issuer) {
return new Response(JSON.stringify({ error: "Issuer URL is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Ensure issuer URL ends without trailing slash for well-known discovery
const cleanIssuer = issuer.replace(/\/$/, "");
const discoveryUrl = `${cleanIssuer}/.well-known/openid-configuration`;
try {
// Fetch OIDC discovery document
const response = await fetch(discoveryUrl);
if (!response.ok) {
throw new Error(`Failed to fetch discovery document: ${response.status}`);
}
const config = await response.json();
// Extract the essential endpoints
const discoveredConfig = {
issuer: config.issuer || cleanIssuer,
authorizationEndpoint: config.authorization_endpoint,
tokenEndpoint: config.token_endpoint,
userInfoEndpoint: config.userinfo_endpoint,
jwksEndpoint: config.jwks_uri,
// Additional useful fields
scopes: config.scopes_supported || ["openid", "profile", "email"],
responseTypes: config.response_types_supported || ["code"],
grantTypes: config.grant_types_supported || ["authorization_code"],
// Suggested domain from issuer
suggestedDomain: new URL(cleanIssuer).hostname.replace("www.", ""),
};
return new Response(JSON.stringify(discoveredConfig), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("OIDC discovery error:", error);
return new Response(
JSON.stringify({
error: "Failed to discover OIDC configuration",
details: error instanceof Error ? error.message : "Unknown error"
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
} catch (error) {
return createSecureErrorResponse(error, "SSO discover API");
}
}

View File

@@ -0,0 +1,152 @@
import type { APIContext } from "astro";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
import { db, ssoProviders } from "@/lib/db";
import { nanoid } from "nanoid";
import { eq } from "drizzle-orm";
// GET /api/sso/providers - List all SSO providers
export async function GET(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const providers = await db.select().from(ssoProviders);
return new Response(JSON.stringify(providers), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO providers API");
}
}
// POST /api/sso/providers - Create a new SSO provider
export async function POST(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const body = await context.request.json();
const {
issuer,
domain,
clientId,
clientSecret,
authorizationEndpoint,
tokenEndpoint,
jwksEndpoint,
userInfoEndpoint,
mapping,
providerId,
organizationId,
} = body;
// Validate required fields
if (!issuer || !domain || !providerId) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
// Check if provider ID already exists
const existing = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.providerId, providerId))
.limit(1);
if (existing.length > 0) {
return new Response(
JSON.stringify({ error: "Provider ID already exists" }),
{
status: 409,
headers: { "Content-Type": "application/json" },
}
);
}
// Create OIDC config object
const oidcConfig = {
clientId,
clientSecret,
authorizationEndpoint,
tokenEndpoint,
jwksEndpoint,
userInfoEndpoint,
mapping: mapping || {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
},
};
// Insert new provider
const [newProvider] = await db
.insert(ssoProviders)
.values({
id: nanoid(),
issuer,
domain,
oidcConfig: JSON.stringify(oidcConfig),
userId: user.id,
providerId,
organizationId,
})
.returning();
return new Response(JSON.stringify(newProvider), {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO providers API");
}
}
// DELETE /api/sso/providers - Delete a provider by ID
export async function DELETE(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const url = new URL(context.request.url);
const providerId = url.searchParams.get("id");
if (!providerId) {
return new Response(
JSON.stringify({ error: "Provider ID is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const deleted = await db
.delete(ssoProviders)
.where(eq(ssoProviders.id, providerId))
.returning();
if (deleted.length === 0) {
return new Response(JSON.stringify({ error: "Provider not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO providers API");
}
}