diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3631ad91..0dde614b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,7 +21,8 @@ "qwtel.sqlite-viewer", "ms-kubernetes-tools.vscode-kubernetes-tools", "vitest.explorer", - "ms-vscode.vscode-chat-customizations-evaluations" + "ms-vscode.vscode-chat-customizations-evaluations", + "TypeScriptTeam.native-preview" ] } }, diff --git a/prisma/migrations/20260802000000_add_sso_provider/migration.sql b/prisma/migrations/20260802000000_add_sso_provider/migration.sql new file mode 100644 index 00000000..77d3dffb --- /dev/null +++ b/prisma/migrations/20260802000000_add_sso_provider/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "SsoProvider" ( + "id" TEXT NOT NULL PRIMARY KEY, + "type" TEXT NOT NULL, + "name" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "clientId" TEXT NOT NULL, + "clientSecretEnc" TEXT NOT NULL, + "issuer" TEXT, + "tenantId" TEXT, + "defaultUserGroupId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "SsoProvider_defaultUserGroupId_fkey" FOREIGN KEY ("defaultUserGroupId") REFERENCES "UserGroup" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +CREATE UNIQUE INDEX "SsoProvider_type_name_key" ON "SsoProvider"("type", "name"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 243bc32e..6139684b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,7 +72,7 @@ model User { name String? email String @unique emailVerified DateTime? - password String + password String @default("") twoFaSecret String? twoFaEnabled Boolean @default(false) apiOnlyUser Boolean @default(false) @@ -140,11 +140,37 @@ model UserGroup { users User[] roleProjectPermissions RoleProjectPermission[] + ssoProviders SsoProvider[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } +enum SsoProviderType { + OIDC + GOOGLE + AZURE_AD + GITHUB +} + +model SsoProvider { + id String @id @default(uuid()) + type SsoProviderType + name String + enabled Boolean @default(false) + clientId String + clientSecretEnc String + issuer String? + tenantId String? + defaultUserGroupId String + defaultUserGroup UserGroup @relation(fields: [defaultUserGroupId], references: [id], onDelete: Restrict) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([type, name]) +} + model RoleProjectPermission { id String @id @default(uuid()) userGroup UserGroup @relation(fields: [userGroupId], references: [id], onDelete: Cascade) diff --git a/public/sso-provider-logos/entra.svg b/public/sso-provider-logos/entra.svg new file mode 100644 index 00000000..1f739764 --- /dev/null +++ b/public/sso-provider-logos/entra.svg @@ -0,0 +1 @@ +MS-SymbolLockup \ No newline at end of file diff --git a/public/sso-provider-logos/github.svg b/public/sso-provider-logos/github.svg new file mode 100644 index 00000000..4f46bf35 --- /dev/null +++ b/public/sso-provider-logos/github.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/sso-provider-logos/google.png b/public/sso-provider-logos/google.png new file mode 100644 index 00000000..cd6d16ac Binary files /dev/null and b/public/sso-provider-logos/google.png differ diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index d980466e..94cdd911 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,7 +1,11 @@ -import NextAuth, { } from "next-auth" -import { authOptions } from "@/server/utils/auth-options"; +import NextAuth from "next-auth"; +import { buildAuthOptions } from "@/server/utils/auth-options"; +async function handler( + req: Request, + ctx: { params: Promise<{ nextauth: string[] }> }, +) { + return NextAuth(await buildAuthOptions())(req, { params: await ctx.params }); +} -const handler = NextAuth(authOptions) - -export { handler as GET, handler as POST } \ No newline at end of file +export { handler as GET, handler as POST }; diff --git a/src/app/auth/login-form.tsx b/src/app/auth/login-form.tsx index 3d2a8296..25b0d32c 100644 --- a/src/app/auth/login-form.tsx +++ b/src/app/auth/login-form.tsx @@ -18,10 +18,20 @@ import { authUser } from "./actions" import { signIn } from "next-auth/react"; import LoadingSpinner from "@/components/ui/loading-spinner" import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Card, CardContent, CardFooter } from "@/components/ui/card" import TwoFaAuthForm from "./two-fa-auth" +import { SsoProviderType } from "@/shared/model/sso-provider.model"; +import { SsoProviderLogo } from "@/components/custom/sso-provider-logo"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Separator } from "@/components/ui/separator"; -export default function UserLoginForm() { +type SsoLoginProvider = { + id: string; + name: string; + type: SsoProviderType; +}; + +export default function UserLoginForm({ ssoProviders }: { ssoProviders: SsoLoginProvider[] }) { const form = useForm, unknown, z.output>({ resolver: zodResolver(authFormInputSchemaZod) }); @@ -73,20 +83,16 @@ export default function UserLoginForm() { } return ( - - - Sign In - Enter your email and password to access your account. - +
{ e.preventDefault(); return form.handleSubmit(async (data) => { await login(data); })(); - }} className="space-y-8"> + }} className="space-y-6"> - + E-Mail - + @@ -108,19 +120,52 @@ export default function UserLoginForm() { Password - + )} /> - -

{errorMessages}

- + + {errorMessages && ( + + {errorMessages} + + )} + + {ssoProviders.length > 0 && ( + +
+ + or continue with + +
+ {ssoProviders.map((provider) => ( + + ))} +
+ )}
) } diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx index ddbbfb50..ab2cf473 100644 --- a/src/app/auth/page.tsx +++ b/src/app/auth/page.tsx @@ -1,10 +1,15 @@ -'use server' - import userService from "@/server/services/user.service"; import UserRegistrationForm from "./register-from"; import UserLoginForm from "./login-form"; import { getUserSession } from "@/server/utils/action-wrapper.utils"; import { redirect } from "next/navigation"; +import ssoProviderService from "@/server/services/sso-provider.service"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Authentication", + description: "Authentication", +}; export default async function AuthPage() { const session = await getUserSession(); @@ -12,9 +17,15 @@ export default async function AuthPage() { redirect('/'); } const allUsers = await userService.getAllUsers(); + const ssoProviders = (await ssoProviderService.getAll()) + .filter((provider) => provider.enabled) + .map(({ id, name, type }) => ({ id, name, type })); return ( -
- {allUsers.length === 0 ? : } -
+
+
+
+ {allUsers.length === 0 ? : } +
+
) -} \ No newline at end of file +} diff --git a/src/app/settings/users/actions.ts b/src/app/settings/users/actions.ts index 11b4a8fe..ead1b3e0 100644 --- a/src/app/settings/users/actions.ts +++ b/src/app/settings/users/actions.ts @@ -12,6 +12,31 @@ import restApiKeyService from "@/server/services/rest-api-key.service"; import { RestApiKeyCreateModel, restApiKeyCreateZodModel } from "@/shared/model/rest-api-key.model"; import { CryptoUtils } from "@/server/utils/crypto.utils"; import { z } from "zod"; +import ssoProviderService from "@/server/services/sso-provider.service"; +import { SsoProviderEditModel, ssoProviderEditZodModel } from "@/shared/model/sso-provider.model"; +import { FormValidationException } from "@/shared/model/form-validation-exception.model"; + +export const saveSsoProvider = async (prevState: any, inputData: SsoProviderEditModel) => + saveFormAction(inputData, ssoProviderEditZodModel, async (validatedData) => { + await getAdminUserSession(); + if (validatedData.type === "OIDC" && !validatedData.issuer) { + throw new FormValidationException("Please correct the errors in the form.", { + issuer: ["Issuer is required for OIDC."], + }); + } + if (validatedData.type === "AZURE_AD" && !validatedData.tenantId) { + throw new FormValidationException("Please correct the errors in the form.", { + tenantId: ["Tenant ID is required for Azure AD."], + }); + } + return await ssoProviderService.save(validatedData); + }); + +export const deleteSsoProvider = async (id: string) => simpleAction(async () => { + await getAdminUserSession(); + await ssoProviderService.deleteById(id); + return new SuccessActionResult(undefined, "SSO provider deleted"); +}); export const saveUser = async (prevState: any, inputData: UserEditModel) => saveFormAction(inputData, userEditZodModel, async (validatedData) => { diff --git a/src/app/settings/users/page.tsx b/src/app/settings/users/page.tsx index f5f5adb2..e9bd8a36 100644 --- a/src/app/settings/users/page.tsx +++ b/src/app/settings/users/page.tsx @@ -6,7 +6,7 @@ import BreadcrumbSetter from "@/components/breadcrumbs-setter"; import UsersTable from "./users-table"; import userService from "@/server/services/user.service"; import userGroupService from "@/server/services/user-group.service"; -import { CircleUser, UserRoundCog } from "lucide-react"; +import { CircleUser, KeyRound, UserRoundCog } from "lucide-react"; import { Tabs, TabsContent, @@ -15,6 +15,8 @@ import { } from "@/components/ui/tabs" import UserGroupsTable from "./user-groups-table"; import projectService from "@/server/services/project.service"; +import ssoProviderService from "@/server/services/sso-provider.service"; +import SsoProvidersTable from "./sso-providers-table"; export default async function UsersAndGroupsPage() { @@ -22,6 +24,7 @@ export default async function UsersAndGroupsPage() { const users = await userService.getAllUsers(); const userGroups = await userGroupService.getAll(); const allApps = await projectService.getAll(); + const ssoProviders = await ssoProviderService.getAll(); return (
Users Groups + SSO Providers - + + + +
) diff --git a/src/app/settings/users/sso-provider-edit-overlay.tsx b/src/app/settings/users/sso-provider-edit-overlay.tsx new file mode 100644 index 00000000..4c4a384b --- /dev/null +++ b/src/app/settings/users/sso-provider-edit-overlay.tsx @@ -0,0 +1,293 @@ +"use client"; + +import type { z } from "zod"; +import { useActionState, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { SubmitButton } from "@/components/custom/submit-button"; +import { FormUtils } from "@/frontend/utils/form.utilts"; +import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; +import { + SsoProviderEditModel, + ssoProviderEditZodModel, + SsoProviderUiModel, + ssoProviderTypes, + formatSsoProviderType, +} from "@/shared/model/sso-provider.model"; +import { UserGroupExtended } from "@/shared/model/sim-session.model"; +import { saveSsoProvider } from "./actions"; +import { useDialog } from "@/frontend/states/zustand.states"; +import { useDialogContext } from "@/frontend/states/dialog-context"; +import { toast } from "sonner"; + +function SsoProviderRedirectUrlDialog({ redirectUrl }: { redirectUrl: string }) { + const { closeDialog } = useDialogContext(); + + return ( +
+
+

SSO Provider Created

+

+ Add this redirect URL to your identity provider. +

+
+ +
+ + +
+
+ ); +} + +export default function SsoProviderEditOverlay({ + children, + provider, + userGroups, +}: { + children: React.ReactNode; + provider?: SsoProviderUiModel; + userGroups: UserGroupExtended[]; +}) { + const [open, setOpen] = useState(false); + const { openDialog } = useDialog(); + const form = useForm< + z.input, + unknown, + z.output + >({ + resolver: zodResolver(ssoProviderEditZodModel), + defaultValues: provider ?? { + type: "OIDC", + enabled: false, + clientSecret: "", + issuer: "", + tenantId: "", + }, + }); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: SsoProviderEditModel) => + saveSsoProvider(state, { ...payload, id: provider?.id }), + FormUtils.getInitialFormState(), + ); + const type = form.watch("type"); + useEffect(() => { + form.reset( + provider + ? { ...provider, clientSecret: "" } + : { + type: "OIDC", + enabled: false, + clientSecret: "", + issuer: "", + tenantId: "", + }, + ); + }, [form, provider, open]); + useEffect(() => { + if (state.status === "success") { + toast.success("SSO provider saved"); + setOpen(false); + if (!provider && state.data?.id) { + void openDialog( + , + { maxWidth: "640px" }, + ); + } + } + FormUtils.mapValidationErrorsToForm( + state, + form, + ); + }, [form, openDialog, provider, state]); + return ( + <> +
setOpen(true)}>{children}
+ + + + {provider ? "Edit" : "Add"} SSO Provider + +
+ form.handleSubmit((data) => formAction(data))()} + className="space-y-4" + > + ( + + Type + + + + )} + /> + ( + + Display Name + + + + + + )} + /> + ( + + Client ID + + + + + + )} + /> + ( + + Client Secret + + + + + + )} + /> + {type === "OIDC" && ( + ( + + Issuer URL + + + + + + )} + /> + )} + {type === "AZURE_AD" && ( + ( + + Tenant ID + + + + + + )} + /> + )} + ( + + Default Group + + + + )} + /> + ( + + Enabled + + + + + )} + /> +

{state.message}

+ Save + + +
+
+ + ); +} diff --git a/src/app/settings/users/sso-providers-table.tsx b/src/app/settings/users/sso-providers-table.tsx new file mode 100644 index 00000000..12cdb580 --- /dev/null +++ b/src/app/settings/users/sso-providers-table.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { CopyIcon, EditIcon, Plus, TrashIcon } from "lucide-react"; +import { SimpleDataTable } from "@/components/custom/simple-data-table"; +import { formatDateTime } from "@/frontend/utils/format.utils"; +import { Toast } from "@/frontend/utils/toast.utils"; +import { useConfirmDialog } from "@/frontend/states/zustand.states"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { deleteSsoProvider } from "./actions"; +import SsoProviderEditOverlay from "./sso-provider-edit-overlay"; +import { formatSsoProviderType, SsoProviderUiModel } from "@/shared/model/sso-provider.model"; +import { UserGroupExtended } from "@/shared/model/sim-session.model"; +import { toast } from "sonner"; + +export default function SsoProvidersTable({ + ssoProviders, + userGroups, +}: { + ssoProviders: SsoProviderUiModel[]; + userGroups: UserGroupExtended[]; +}) { + const { openConfirmDialog } = useConfirmDialog(); + async function remove(id: string) { + if ( + await openConfirmDialog({ + title: "Delete SSO Provider", + description: "Remove this provider? Existing linked accounts remain, but new login attempts cannot use it.", + okButton: "Delete", + }) + ) { + await Toast.fromAction(() => deleteSsoProvider(id)); + } + } + function copyRedirectUrl(id: string) { + const redirectUrl = `${window.location.origin}/api/auth/callback/${id}`; + navigator.clipboard.writeText(redirectUrl); + toast.success("Redirect URL copied"); + } + return ( + <> + formatSsoProviderType(item.type)], + ["name", "Display Name", true], + ["enabled", "Enabled", true, (item) => (item.enabled ? "Yes" : "No")], + ["defaultUserGroupId", "Default Group", true, (item) => + userGroups.find((group) => group.id === item.defaultUserGroupId)?.name ?? "—", + ], + ["createdAt", "Created", false, (item) => formatDateTime(item.createdAt)], + ]} + data={ssoProviders} + actionCol={(item) => ( +
+
+ + + + + + Copy redirect URL to clipboard + + + + + + +
+ )} + /> + + + + + ); +} diff --git a/src/app/settings/users/users-table.tsx b/src/app/settings/users/users-table.tsx index 74901753..e936a49f 100644 --- a/src/app/settings/users/users-table.tsx +++ b/src/app/settings/users/users-table.tsx @@ -11,6 +11,7 @@ import { UserExtended } from "@/shared/model/user-extended.model"; import UserEditOverlay from "./user-edit-overlay"; import { deleteUser } from "./actions"; import { UserGroupExtended, UserSession } from "@/shared/model/sim-session.model"; +import { SsoProviderUiModel } from "@/shared/model/sso-provider.model"; import { DropdownMenu, DropdownMenuContent, @@ -22,11 +23,14 @@ import UsersBulkRoleAssignment from "./users-table-bulk-role-assignment"; import { Actions } from "@/frontend/utils/nextjs-actions.utils"; import { useDialog } from "@/frontend/states/zustand.states"; import { UserApiKeysDialog } from "./user-api-keys-dialog"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { SsoProviderLogo } from "@/components/custom/sso-provider-logo"; -export default function UsersTable({ users, userGroups, session }: { +export default function UsersTable({ users, userGroups, session, ssoProviders }: { users: UserExtended[]; userGroups: UserGroupExtended[]; session: UserSession; + ssoProviders: SsoProviderUiModel[]; }) { const { openConfirmDialog: openDialog } = useConfirmDialog(); @@ -79,6 +83,34 @@ export default function UsersTable({ users, userGroups, session }: { ['id', 'ID', false], ['email', 'Mail', true, (item) => {item.email}{item.apiOnlyUser && API only}], ['userGroup.name', 'Group', true], + ['oauthProviderIds', 'OAuth Providers', true, (item) => { + const providers = item.oauthProviderIds + .map((providerId) => ssoProviders.find((provider) => provider.id === providerId)) + .filter((provider): provider is SsoProviderUiModel => !!provider); + + if (providers.length === 0) return "None"; + + return ( + +
+ {providers.map((provider) => + provider.type !== "OIDC" ? ( + + + + + + + {provider.name} + + ) : ( + {provider.name} + ), + )} +
+
+ ); + }], ["createdAt", "Created At", true, (item) => formatDateTime(item.createdAt)], ["updatedAt", "Updated At", false, (item) => formatDateTime(item.updatedAt)], ]} diff --git a/src/components/custom/sso-provider-logo.tsx b/src/components/custom/sso-provider-logo.tsx new file mode 100644 index 00000000..5e6d1777 --- /dev/null +++ b/src/components/custom/sso-provider-logo.tsx @@ -0,0 +1,30 @@ +import Image from "next/image"; +import { SsoProviderType } from "@/shared/model/sso-provider.model"; + +const logoByType: Partial> = { + GOOGLE: "/sso-provider-logos/google.png", + AZURE_AD: "/sso-provider-logos/entra.svg", + GITHUB: "/sso-provider-logos/github.svg", +}; + +export function SsoProviderLogo({ + type, + className, +}: { + type: SsoProviderType; + className?: string; +}) { + const src = logoByType[type]; + if (!src) return null; + + return ( + + ); +} diff --git a/src/server/services/sso-provider.service.ts b/src/server/services/sso-provider.service.ts new file mode 100644 index 00000000..264f2392 --- /dev/null +++ b/src/server/services/sso-provider.service.ts @@ -0,0 +1,101 @@ +import { SsoProvider, SsoProviderType } from "@prisma/client"; +import { revalidateTag, unstable_cache } from "next/cache"; +import dataAccess from "@/server/adapter/db.client"; +import { CryptoUtils } from "@/server/utils/crypto.utils"; +import { Tags } from "@/server/utils/cache-tag-generator.utils"; +import { ServiceException } from "@/shared/model/service.exception.model"; +import { + SsoProviderEditModel, + SsoProviderUiModel, +} from "@/shared/model/sso-provider.model"; + +type AuthProvider = Omit & { + clientSecret: string; +}; + +class SsoProviderService { + private toUiModel(provider: SsoProvider): SsoProviderUiModel { + return { + id: provider.id, + type: provider.type, + name: provider.name, + enabled: provider.enabled, + clientId: provider.clientId, + issuer: provider.issuer ?? "", + tenantId: provider.tenantId ?? "", + defaultUserGroupId: provider.defaultUserGroupId, + hasClientSecret: !!provider.clientSecretEnc, + createdAt: provider.createdAt, + updatedAt: provider.updatedAt, + }; + } + + async getAll(): Promise { + const providers = await dataAccess.client.ssoProvider.findMany({ orderBy: { name: "asc" } }); + return providers.map((provider) => this.toUiModel(provider)); + } + + async getById(id: string) { + return dataAccess.client.ssoProvider.findUnique({ where: { id } }); + } + + async getEnabledForAuth(): Promise { + return await unstable_cache( + async () => { + const providers = await dataAccess.client.ssoProvider.findMany({ where: { enabled: true } }); + return providers.map(({ clientSecretEnc, ...provider }) => ({ + ...provider, + clientSecret: CryptoUtils.decrypt(clientSecretEnc), + })); + }, + [Tags.ssoProviders(), "enabled-for-auth"], + { tags: [Tags.ssoProviders()] }, + )(); + } + + async save(input: SsoProviderEditModel) { + const enteredSecret = input.clientSecret.trim(); + if (!input.id && !enteredSecret) { + throw new ServiceException("Client secret is required."); + } + const clientSecretEnc = enteredSecret + ? CryptoUtils.encrypt(enteredSecret) + : ( + await dataAccess.client.ssoProvider.findUniqueOrThrow({ + where: { id: input.id! }, + select: { clientSecretEnc: true }, + }) + ).clientSecretEnc; + + const data = { + type: input.type as SsoProviderType, + name: input.name.trim(), + enabled: input.enabled, + clientId: input.clientId.trim(), + clientSecretEnc, + issuer: input.type === "OIDC" ? input.issuer.trim() : null, + tenantId: input.type === "AZURE_AD" ? input.tenantId.trim() : null, + defaultUserGroupId: input.defaultUserGroupId, + }; + + try { + const provider = input.id + ? await dataAccess.client.ssoProvider.update({ where: { id: input.id }, data }) + : await dataAccess.client.ssoProvider.create({ data }); + return this.toUiModel(provider); + } finally { + revalidateTag(Tags.ssoProviders()); + } + } + + async deleteById(id: string) { + try { + await dataAccess.client.ssoProvider.delete({ where: { id } }); + } finally { + revalidateTag(Tags.ssoProviders()); + } + } +} + +const ssoProviderService = new SsoProviderService(); +export default ssoProviderService; diff --git a/src/server/services/user.service.ts b/src/server/services/user.service.ts index 8077f1b1..8d101e42 100644 --- a/src/server/services/user.service.ts +++ b/src/server/services/user.service.ts @@ -130,12 +130,17 @@ export class UserService { updatedAt: true, userGroup: true, apiOnlyUser: true, + accounts: { select: { provider: true } }, _count: { select: { restApiKeys: true } } } }), [Tags.users()], { tags: [Tags.users()] - })().then(users => users.map(({ _count, ...user }) => ({ ...user, apiKeyCount: _count.restApiKeys }))); + })().then(users => users.map(({ _count, accounts, ...user }) => ({ + ...user, + apiKeyCount: _count.restApiKeys, + oauthProviderIds: [...new Set(accounts.map((account) => account.provider))], + }))); } async getUserById(id: string): Promise { @@ -151,9 +156,14 @@ export class UserService { updatedAt: true, userGroup: true, apiOnlyUser: true, + accounts: { select: { provider: true } }, _count: { select: { restApiKeys: true } } } - }).then(({ _count, ...user }) => ({ ...user, apiKeyCount: _count.restApiKeys })); + }).then(({ _count, accounts, ...user }) => ({ + ...user, + apiKeyCount: _count.restApiKeys, + oauthProviderIds: [...new Set(accounts.map((account) => account.provider))], + })); } async getUserByEmail(email: string) { @@ -164,6 +174,30 @@ export class UserService { }); } + async findUserByEmail(email: string) { + return dataAccess.client.user.findUnique({ + where: { + email + } + }); + } + + async setUserGroup(userId: string, userGroupId: string) { + try { + await dataAccess.client.user.update({ + where: { + id: userId + }, + data: { + userGroupId + } + }); + } finally { + revalidateTag(Tags.users()); + revalidateTag(Tags.userGroups()); + } + } + async createNewTotpToken(userMail: string) { try { await this.getUserByEmail(userMail); diff --git a/src/server/utils/action-wrapper.utils.ts b/src/server/utils/action-wrapper.utils.ts index 59a77cb9..b5c979b6 100644 --- a/src/server/utils/action-wrapper.utils.ts +++ b/src/server/utils/action-wrapper.utils.ts @@ -5,7 +5,7 @@ import { ZodRawShape, ZodObject, z } from "zod"; import { redirect } from "next/navigation"; import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; import { FormValidationException } from "@/shared/model/form-validation-exception.model"; -import { authOptions } from "@/server/utils/auth-options"; +import { buildAuthOptions } from "@/server/utils/auth-options"; import { NextResponse } from "next/server"; import userGroupService from "../services/user-group.service"; import { UserGroupUtils } from "../../shared/utils/role.utils"; @@ -27,7 +27,7 @@ import { WorkloadType, zodWorkloadType } from "@/shared/model/runtime-type.model * use getAuthUserSession() if you want to throw an error if no user is logged in */ export async function getUserSession(): Promise { - const session = await getServerSession(authOptions); + const session = await getServerSession(await buildAuthOptions()); if (!session) { return null; } diff --git a/src/server/utils/auth-options.ts b/src/server/utils/auth-options.ts index 1343e500..f4357f29 100644 --- a/src/server/utils/auth-options.ts +++ b/src/server/utils/auth-options.ts @@ -1,87 +1,167 @@ import { User } from "@prisma/client"; -import { NextAuthOptions } from "next-auth" -import { PrismaAdapter } from "@next-auth/prisma-adapter" -import dataAccess from "@/server/adapter/db.client"; +import { NextAuthOptions } from "next-auth"; +import { AdapterAccount, AdapterUser } from "next-auth/adapters"; +import { PrismaAdapter } from "@next-auth/prisma-adapter"; import CredentialsProvider from "next-auth/providers/credentials"; +import GoogleProvider from "next-auth/providers/google"; +import AzureADProvider from "next-auth/providers/azure-ad"; +import GitHubProvider from "next-auth/providers/github"; +import dataAccess from "@/server/adapter/db.client"; import userService from "@/server/services/user.service"; +import ssoProviderService from "@/server/services/sso-provider.service"; import { UserSession } from "@/shared/model/sim-session.model"; -export const authOptions: NextAuthOptions = { - session: { - strategy: "jwt", - }, - pages: { - signIn: "/auth", - }, +function mapOidcProfile(profile: Record) { + return { + id: profile.sub as string, + // Microsoft Entra ID does not guarantee an `email` claim for work and + // school accounts. Its `preferred_username` claim is normally the UPN. + email: + (profile.email as string | undefined) ?? + (profile.preferred_username as string | undefined) ?? + (profile.upn as string | undefined), + name: + (profile.name as string | undefined) ?? + (profile.preferred_username as string | undefined), + image: (profile.picture as string | null | undefined) ?? null, + }; +} + +export function mapToNextAuthProvider( + provider: Awaited< + ReturnType + >[number], +) { + const common = { + id: provider.id, + name: provider.name, + clientId: provider.clientId, + clientSecret: provider.clientSecret, + allowDangerousEmailAccountLinking: true, + }; + + switch (provider.type) { + case "OIDC": + return { + ...common, + type: "oauth" as const, + wellKnown: `${provider.issuer!.replace(/\/$/, "")}/.well-known/openid-configuration`, + authorization: { params: { scope: "openid email profile" } }, + profile: mapOidcProfile, + }; + case "GOOGLE": + return GoogleProvider(common); + case "AZURE_AD": + return AzureADProvider({ + ...common, + tenantId: provider.tenantId!, + profile: mapOidcProfile, + }); + case "GITHUB": + return GitHubProvider(common); + } +} + +export async function buildAuthOptions(): Promise { + const ssoProviders = await ssoProviderService.getEnabledForAuth(); + return { + session: { strategy: "jwt" }, + pages: { signIn: "/auth" }, providers: [ - CredentialsProvider({ - // The name to display on the sign in form (e.g. "Sign in with...") - name: "Credentials", - // `credentials` is used to generate a form on the sign in page. - // You can specify which fields should be submitted, by adding keys to the `credentials` object. - // e.g. domain, username, password, 2FA token, etc. - // You can pass any HTML attribute to the tag through the object. - credentials: { - username: { label: "Username", type: "text" }, - password: { label: "Password", type: "password" }, - totpToken: { label: "TOTP Token", type: "text" }, - }, - async authorize(credentials) { - if (!credentials) { - return null; - } - const authUserInfo = await userService.authorize(credentials); - if (!authUserInfo) { - return null; - } - const user = await userService.getUserByEmail(authUserInfo.email); - if (user.twoFaEnabled) { - if (!credentials.totpToken) { - return null; - } - const tokenValid = await userService.verifyTotpToken(authUserInfo.email, credentials.totpToken); - if (!tokenValid) { - return null; - } - } - return mapUser(user); - } - }) + CredentialsProvider({ + name: "Credentials", + credentials: { + username: { label: "Username", type: "text" }, + password: { label: "Password", type: "password" }, + totpToken: { label: "TOTP Token", type: "text" }, + }, + async authorize(credentials) { + if (!credentials) { + return null; + } + const authUserInfo = await userService.authorize( + credentials as Record<"password" | "username", string>, + ); + if (!authUserInfo) { + return null; + } + const user = await userService.getUserByEmail(authUserInfo.email); + if ( + user.twoFaEnabled && + (!credentials.totpToken || + !(await userService.verifyTotpToken( + authUserInfo.email, + credentials.totpToken, + ))) + ) { + return null; + } + return mapUser(user); + }, + }), + ...ssoProviders.map(mapToNextAuthProvider), ], callbacks: { - async jwt(data) { - // Initial sign in - store user info in token - if (data.token && data.token.email) { - const user = await userService.getUserByEmail(data.token.email); - const userId = user.id; - if (userId) { - data.token.userId = userId; - } - } - return data.token; - }, - async session({ session, token, user }) { - // Read user info from token and builds session object - if (token?.userId) { - const userSession = session.user as UserSession; - userSession.userId = token.userId as string; - session.user = userSession; - return session; - } - - console.error('Could not generate session - missing userId in token'); - console.error('session', session); - console.error('token', token); - console.error('user', user); - throw new Error("Could not generate session"); + async jwt(data) { + if (data.token?.email) { + const user = await userService.findUserByEmail(data.token.email); + if (user) { + data.token.userId = user.id; + } } + return data.token; + }, + async session({ session, token }) { + if (token?.userId) { + (session.user as UserSession).userId = token.userId as string; + return session; + } + throw new Error("Could not generate session"); + }, }, - adapter: PrismaAdapter(dataAccess.client), -}; + events: { + async linkAccount({ user, account }) { + if (account.provider === "credentials") { + return; + } + const provider = await ssoProviderService.getById(account.provider); + const existingUser = await userService.findUserByEmail(user.email!); + if (provider && existingUser && !existingUser.userGroupId) { + await userService.setUserGroup( + existingUser.id, + provider.defaultUserGroupId, + ); + } + }, + }, + // OAuth users do not have a local credential. Keep an empty password so + // the existing required User.password column works on upgraded databases. + adapter: { + ...PrismaAdapter(dataAccess.client), + createUser: (user: AdapterUser) => + dataAccess.client.user.create({ data: { ...user, password: "" } }), + // OIDC providers may return non-standard token fields (for example + // Azure's `ext_expires_in`). Prisma only accepts fields in Account. + linkAccount: (account: AdapterAccount) => + dataAccess.client.account.create({ + data: { + userId: account.userId, + type: account.type, + provider: account.provider, + providerAccountId: account.providerAccountId, + refresh_token: account.refresh_token, + access_token: account.access_token, + expires_at: account.expires_at, + token_type: account.token_type, + scope: account.scope, + id_token: account.id_token, + session_state: account.session_state, + }, + }), + }, + }; +} function mapUser(user: User) { - return { - id: user.id, - email: user.email - }; + return { id: user.id, email: user.email }; } diff --git a/src/server/utils/cache-tag-generator.utils.ts b/src/server/utils/cache-tag-generator.utils.ts index bd4f4756..faf54987 100644 --- a/src/server/utils/cache-tag-generator.utils.ts +++ b/src/server/utils/cache-tag-generator.utils.ts @@ -20,6 +20,10 @@ export class Tags { return `llm-gateways`; } + static ssoProviders() { + return `sso-providers`; + } + static volumeBackups() { return `volume-backups`; } diff --git a/src/shared/model/generated-zod/index.ts b/src/shared/model/generated-zod/index.ts index 97f2efe0..c2878974 100644 --- a/src/shared/model/generated-zod/index.ts +++ b/src/shared/model/generated-zod/index.ts @@ -5,6 +5,7 @@ export * from "./restapikey" export * from "./verificationtoken" export * from "./authenticator" export * from "./usergroup" +export * from "./ssoprovider" export * from "./roleprojectpermission" export * from "./roleapppermission" export * from "./roleagentpermission" diff --git a/src/shared/model/generated-zod/ssoprovider.ts b/src/shared/model/generated-zod/ssoprovider.ts new file mode 100644 index 00000000..074bb188 --- /dev/null +++ b/src/shared/model/generated-zod/ssoprovider.ts @@ -0,0 +1,31 @@ +import * as z from "zod" + +import { SsoProviderType } from "@prisma/client" +import { CompleteUserGroup, RelatedUserGroupModel } from "./index" + +export const SsoProviderModel = z.object({ + id: z.string(), + type: z.nativeEnum(SsoProviderType), + name: z.string(), + enabled: z.boolean(), + clientId: z.string(), + clientSecretEnc: z.string(), + issuer: z.string().nullish(), + tenantId: z.string().nullish(), + defaultUserGroupId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +export interface CompleteSsoProvider extends z.infer { + defaultUserGroup: CompleteUserGroup +} + +/** + * RelatedSsoProviderModel contains all relations on your model in addition to the scalars + * + * NOTE: Lazy required in case of potential circular dependencies within schema + */ +export const RelatedSsoProviderModel: z.ZodSchema = z.lazy(() => SsoProviderModel.extend({ + defaultUserGroup: RelatedUserGroupModel, +})) diff --git a/src/shared/model/generated-zod/usergroup.ts b/src/shared/model/generated-zod/usergroup.ts index 7190d224..49cb38fe 100644 --- a/src/shared/model/generated-zod/usergroup.ts +++ b/src/shared/model/generated-zod/usergroup.ts @@ -1,6 +1,6 @@ import * as z from "zod" -import { CompleteUser, RelatedUserModel, CompleteRoleProjectPermission, RelatedRoleProjectPermissionModel } from "./index" +import { CompleteUser, RelatedUserModel, CompleteRoleProjectPermission, RelatedRoleProjectPermissionModel, CompleteSsoProvider, RelatedSsoProviderModel } from "./index" export const UserGroupModel = z.object({ id: z.string(), @@ -14,6 +14,7 @@ export const UserGroupModel = z.object({ export interface CompleteUserGroup extends z.infer { users: CompleteUser[] roleProjectPermissions: CompleteRoleProjectPermission[] + ssoProviders: CompleteSsoProvider[] } /** @@ -24,4 +25,5 @@ export interface CompleteUserGroup extends z.infer { export const RelatedUserGroupModel: z.ZodSchema = z.lazy(() => UserGroupModel.extend({ users: RelatedUserModel.array(), roleProjectPermissions: RelatedRoleProjectPermissionModel.array(), + ssoProviders: RelatedSsoProviderModel.array(), })) diff --git a/src/shared/model/sso-provider.model.ts b/src/shared/model/sso-provider.model.ts new file mode 100644 index 00000000..9da3bf5e --- /dev/null +++ b/src/shared/model/sso-provider.model.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +export const ssoProviderTypes = [ + "OIDC", + "GOOGLE", + "AZURE_AD", + "GITHUB", +] as const; + +export type SsoProviderType = (typeof ssoProviderTypes)[number]; + +export function formatSsoProviderType(type: SsoProviderType): string { + const labels: Record = { + OIDC: "OIDC", + GOOGLE: "Google", + AZURE_AD: "Entra ID", + GITHUB: "GitHub", + }; + + return labels[type]; +} + +export const ssoProviderEditZodModel = z + .object({ + id: z.string().uuid().optional(), + type: z.enum(ssoProviderTypes), + name: z.string().trim().min(1, "Display name is required."), + enabled: z.boolean().default(false), + clientId: z.string().trim().min(1, "Client ID is required."), + clientSecret: z.string().optional().default(""), + issuer: z.string().trim().optional().default(""), + tenantId: z.string().trim().optional().default(""), + defaultUserGroupId: z.string().uuid("Default user group is required."), + }); + +export type SsoProviderEditModel = z.infer; +export type SsoProviderUiModel = Omit & { + id: string; + hasClientSecret: boolean; + createdAt: Date; + updatedAt: Date; +}; diff --git a/src/shared/model/user-extended.model.ts b/src/shared/model/user-extended.model.ts index e290edb3..ef4754c4 100644 --- a/src/shared/model/user-extended.model.ts +++ b/src/shared/model/user-extended.model.ts @@ -9,4 +9,5 @@ export type UserExtended = { updatedAt: Date; apiOnlyUser: boolean; apiKeyCount: number; + oauthProviderIds: string[]; };