import { Hono } from "hono"; import { validator } from "hono-openapi"; import { type PublicSsoProvidersDto, type SsoSettingsDto, type UserSsoInvitationsDto, deleteSsoInvitationDto, deleteSsoProviderDto, getPublicSsoProvidersDto, getSsoSettingsDto, getUserSsoInvitationsDto, startInvitationSsoVerificationBody, startInvitationSsoVerificationDto, updateSsoProviderAutoLinkingBody, updateSsoProviderAutoLinkingDto, } from "./sso.dto"; import { SSO_INVITATION_INTENT_COOKIE, ssoService } from "./sso.service"; import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware"; import { auth } from "~/server/lib/auth"; import { mapAuthErrorToCode } from "./sso.errors"; import { config } from "~/server/core/config"; import { setCookie } from "hono/cookie"; import { normalizeEmail } from "./utils/sso-context"; export const ssoController = new Hono() .get("/sso-providers", getPublicSsoProvidersDto, async (c) => { const providers = await ssoService.getPublicSsoProviders(); return c.json(providers); }) .get("/sso-settings", requireAuth, requireOrgAdmin, getSsoSettingsDto, async (c) => { const headers = c.req.raw.headers; const activeOrganizationId = c.get("organizationId"); if (!activeOrganizationId) { return c.json({ providers: [], invitations: [] }); } const [providersData, invitationsData, autoLinkingSettings] = await Promise.all([ auth.api.listSSOProviders({ headers, query: { organizationId: activeOrganizationId } }), auth.api.listInvitations({ headers, query: { organizationId: activeOrganizationId } }), ssoService.getSsoProviderAutoLinkingSettings(activeOrganizationId), ]); return c.json({ providers: providersData.providers .map((provider) => ({ providerId: provider.providerId, type: provider.type, issuer: provider.issuer, domain: provider.domain, autoLinkMatchingEmails: autoLinkingSettings[provider.providerId] ?? false, organizationId: provider.organizationId, })) .filter((p) => p.organizationId === activeOrganizationId), invitations: invitationsData.map((invitation) => ({ id: invitation.id, email: invitation.email, role: invitation.role, status: invitation.status, expiresAt: invitation.expiresAt.toISOString(), })), }); }) .get("/sso-invitations", requireAuth, getUserSsoInvitationsDto, async (c) => { const user = c.get("user"); const invitations = await ssoService.listPendingInvitationsForUser(user.email); return c.json(invitations); }) .post( "/sso-invitations/:invitationId/verify", requireAuth, startInvitationSsoVerificationDto, validator("json", startInvitationSsoVerificationBody), async (c) => { const user = c.get("user"); const invitationId = c.req.param("invitationId"); const { providerId } = c.req.valid("json"); const invitation = await ssoService.getPendingInvitationById(invitationId); if (!invitation || normalizeEmail(invitation.email) !== normalizeEmail(user.email)) { return c.json({ message: "Invitation not found" }, 404); } const provider = await ssoService.getSsoProviderForOrganization(providerId, invitation.organizationId); if (!provider) { return c.json({ message: "Provider not found for invitation organization" }, 400); } const token = await ssoService.createInvitationSsoIntent({ userId: user.id, invitationId: invitation.id, providerId, organizationId: invitation.organizationId, email: user.email, }); setCookie(c, SSO_INVITATION_INTENT_COOKIE, token, { httpOnly: true, sameSite: "Lax", secure: config.isSecure, path: "/api/auth", maxAge: 10 * 60, }); return c.json({ success: true }); }, ) .delete("/sso-providers/:providerId", requireAuth, requireOrgAdmin, deleteSsoProviderDto, async (c) => { const providerId = c.req.param("providerId"); const organizationId = c.get("organizationId"); const deleted = await ssoService.deleteSsoProvider(providerId, organizationId); if (!deleted) { return c.json({ message: "Provider not found" }, 404); } return c.json({ success: true }); }) .patch( "/sso-providers/:providerId/auto-linking", requireAuth, requireOrgAdmin, updateSsoProviderAutoLinkingDto, validator("json", updateSsoProviderAutoLinkingBody), async (c) => { const providerId = c.req.param("providerId"); const organizationId = c.get("organizationId"); const { enabled } = c.req.valid("json"); const updated = await ssoService.updateSsoProviderAutoLinking(providerId, organizationId, enabled); if (!updated) { return c.json({ message: "Provider not found" }, 404); } return c.json({ success: true }); }, ) .delete("/sso-invitations/:invitationId", requireAuth, requireOrgAdmin, deleteSsoInvitationDto, async (c) => { const invitationId = c.req.param("invitationId"); const organizationId = c.get("organizationId"); const invitation = await ssoService.getSsoInvitationById(invitationId); if (!invitation || invitation.organizationId !== organizationId) { return c.json({ message: "Invitation not found" }, 404); } await ssoService.deleteSsoInvitation(invitationId); return c.json({ success: true }); }) .get("/login-error", async (c) => { const error = c.req.query("error"); const errorCode = error ? mapAuthErrorToCode(error) : "SSO_LOGIN_FAILED"; return c.redirect(`${config.baseUrl}/login?error=${errorCode}`); });