diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 45e9fd7c..1a13c5dd 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -10,6 +10,7 @@ import { idp, idpOidcConfig, idpOrg, + orgs, Role, roles, userOrgs, @@ -214,32 +215,47 @@ export async function validateOidcCallback( ); if (existingIdp.idp.autoProvision) { - const idpOrgs = await db - .select() - .from(idpOrg) - .where(eq(idpOrg.idpId, existingIdp.idp.idpId)); + const allOrgs = await db.select().from(orgs); const defaultRoleMapping = existingIdp.idp.defaultRoleMapping; const defaultOrgMapping = existingIdp.idp.defaultOrgMapping; let userOrgInfo: { orgId: string; roleId: number }[] = []; - for (const idpOrg of idpOrgs) { + for (const org of allOrgs) { + const [idpOrgRes] = await db + .select() + .from(idpOrg) + .where( + and( + eq(idpOrg.idpId, existingIdp.idp.idpId), + eq(idpOrg.orgId, org.orgId) + ) + ); + let roleId: number | undefined = undefined; - const orgMapping = idpOrg.orgMapping || defaultOrgMapping; - const hydratedOrgMapping = orgMapping - ?.split("{{orgId}}") - .join(idpOrg.orgId); + const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping; + const hydratedOrgMapping = hydrateOrgMapping( + orgMapping, + org.orgId + ); if (hydratedOrgMapping) { + logger.debug("Hydrated Org Mapping", { + hydratedOrgMapping + }); const orgId = jmespath.search(claims, hydratedOrgMapping); - if (!(orgId === true || orgId === idpOrg.orgId)) { + logger.debug("Extraced Org ID", { orgId }); + if (orgId !== true && orgId !== org.orgId) { + // user not allowed to access this org continue; } } - const roleMapping = idpOrg.roleMapping || defaultRoleMapping; + const roleMapping = + idpOrgRes?.roleMapping || defaultRoleMapping; if (roleMapping) { + logger.debug("Role Mapping", { roleMapping }); const roleName = jmespath.search(claims, roleMapping); if (!roleName) { @@ -254,14 +270,14 @@ export async function validateOidcCallback( .from(roles) .where( and( - eq(roles.orgId, idpOrg.orgId), + eq(roles.orgId, org.orgId), eq(roles.name, roleName) ) ); if (!roleRes) { logger.error("Role not found", { - orgId: idpOrg.orgId, + orgId: org.orgId, roleName }); continue; @@ -270,7 +286,7 @@ export async function validateOidcCallback( roleId = roleRes.roleId; userOrgInfo.push({ - orgId: idpOrg.orgId, + orgId: org.orgId, roleId }); } @@ -443,3 +459,13 @@ export async function validateOidcCallback( ); } } + +function hydrateOrgMapping( + orgMapping: string | null, + orgId: string +): string | undefined { + if (!orgMapping) { + return undefined; + } + return orgMapping.split("{{orgId}}").join(orgId); +} diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx index 72f8e2b5..760f590b 100644 --- a/src/app/admin/idp/[idpId]/general/page.tsx +++ b/src/app/admin/idp/[idpId]/general/page.tsx @@ -101,7 +101,7 @@ export default function GeneralPage() { emailPath: data.idpOidcConfig.emailPath, namePath: data.idpOidcConfig.namePath, scopes: data.idpOidcConfig.scopes, - autoProvision: data.idpOidcConfig.autoProvision + autoProvision: data.idp.autoProvision }); } } catch (e) { diff --git a/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx index 1d6ec80e..c946869b 100644 --- a/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx @@ -22,7 +22,7 @@ type ValidateOidcTokenParams = { code: string | undefined; expectedState: string | undefined; stateCookie: string | undefined; - idp: {name: string}; + idp: { name: string }; }; export default function ValidateOidcToken(props: ValidateOidcTokenParams) { @@ -64,11 +64,10 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { await new Promise((resolve) => setTimeout(resolve, 100)); if (redirectUrl.startsWith("http")) { - window.location.href = res.data.data.redirectUrl; // TODO: validate this to make sure it's safe + window.location.href = res.data.data.redirectUrl; // this is validated by the parent using this component } else { router.push(res.data.data.redirectUrl); } - } catch (e) { setError(formatAxiosError(e, "Error validating OIDC token")); } finally { @@ -103,8 +102,12 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { - There was a problem connecting to {props.idp.name}. Please contact your administrator. - {error} + + There was a problem connecting to{" "} + {props.idp.name}. Please contact your + administrator. + + {error} )} diff --git a/src/app/auth/login/DashboardLoginForm.tsx b/src/app/auth/login/DashboardLoginForm.tsx index 715a0fb9..b15dd518 100644 --- a/src/app/auth/login/DashboardLoginForm.tsx +++ b/src/app/auth/login/DashboardLoginForm.tsx @@ -8,7 +8,7 @@ import { CardTitle } from "@/components/ui/card"; import { createApiClient } from "@app/lib/api"; -import LoginForm from "@app/components/LoginForm"; +import LoginForm, { LoginFormIDP } from "@app/components/LoginForm"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; @@ -17,10 +17,12 @@ import { cleanRedirect } from "@app/lib/cleanRedirect"; type DashboardLoginFormProps = { redirect?: string; + idps?: LoginFormIDP[]; }; export default function DashboardLoginForm({ - redirect + redirect, + idps }: DashboardLoginFormProps) { const router = useRouter(); // const api = createApiClient(useEnvContext()); @@ -51,12 +53,15 @@ export default function DashboardLoginForm({

Welcome to Pangolin

-

Log in to get started

+

+ Log in to get started +

{ if (redirect) { const safe = cleanRedirect(redirect); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index e10c18ce..e4a93577 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -6,6 +6,9 @@ import DashboardLoginForm from "./DashboardLoginForm"; import { Mail } from "lucide-react"; import { pullEnv } from "@app/lib/pullEnv"; import { cleanRedirect } from "@app/lib/cleanRedirect"; +import db from "@server/db"; +import { idp } from "@server/db/schemas"; +import { LoginFormIDP } from "@app/components/LoginForm"; export const dynamic = "force-dynamic"; @@ -31,6 +34,12 @@ export default async function Page(props: { redirectUrl = cleanRedirect(searchParams.redirect as string); } + const idps = await db.select().from(idp); + const loginIdps = idps.map((idp) => ({ + idpId: idp.idpId, + name: idp.name + })) as LoginFormIDP[]; + return ( <> {isInvite && ( @@ -48,7 +57,7 @@ export default async function Page(props: { )} - + {(!signUpDisabled || isInvite) && (

diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index 77dd5d51..d7efa59c 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -33,7 +33,7 @@ import { useRouter } from "next/navigation"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { formatAxiosError } from "@app/lib/api"; import { AxiosResponse } from "axios"; -import LoginForm from "@app/components/LoginForm"; +import LoginForm, { LoginFormIDP } from "@app/components/LoginForm"; import { AuthWithPasswordResponse, AuthWithWhitelistResponse @@ -81,6 +81,7 @@ type ResourceAuthPortalProps = { id: number; }; redirect: string; + idps?: LoginFormIDP[]; }; export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { @@ -490,6 +491,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { className={`${numMethods <= 1 ? "mt-0" : ""}`} > await handleSSOAuth() diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index 006faa45..af31de98 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -13,6 +13,9 @@ import ResourceNotFound from "./ResourceNotFound"; import ResourceAccessDenied from "./ResourceAccessDenied"; import AccessToken from "./AccessToken"; import { pullEnv } from "@app/lib/pullEnv"; +import { LoginFormIDP } from "@app/components/LoginForm"; +import db from "@server/db"; +import { idp } from "@server/db/schemas"; export default async function ResourceAuthPage(props: { params: Promise<{ resourceId: number }>; @@ -84,7 +87,6 @@ export default async function ResourceAuthPage(props: { redirect(redirectUrl); } - // convert the dashboard token into a resource session token let userIsUnauthorized = false; if (user && authInfo.sso) { @@ -128,6 +130,12 @@ export default async function ResourceAuthPage(props: { ); } + const idps = await db.select().from(idp); + const loginIdps = idps.map((idp) => ({ + idpId: idp.idpId, + name: idp.name + })) as LoginFormIDP[]; + return ( <> {userIsUnauthorized && isSSOOnly ? ( @@ -148,6 +156,7 @@ export default async function ResourceAuthPage(props: { id: authInfo.resourceId }} redirect={redirectUrl} + idps={loginIdps} /> )} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 2dcfd022..1c697ce4 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -90,29 +90,32 @@ export function Layout({

- - setIsMobileMenuOpen( - false - ) - } - /> + + setIsMobileMenuOpen( + false + ) + } + />
- {!isAdminPage && ( -
- - setIsMobileMenuOpen(false) - } - > - - Server Admin - -
- )} + {!isAdminPage && + user.serverAdmin && ( +
+ + setIsMobileMenuOpen( + false + ) + } + > + + Server Admin + +
+ )}
@@ -234,7 +237,7 @@ export function Layout({ )} >
-
+
{children}
diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index e4564c02..9a60a217 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -39,10 +39,17 @@ import Link from "next/link"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; import Image from "next/image"; import { GenerateOidcUrlResponse } from "@server/routers/idp"; +import { Separator } from "./ui/separator"; + +export type LoginFormIDP = { + idpId: number; + name: string; +}; type LoginFormProps = { redirect?: string; onLogin?: () => void | Promise; + idps?: LoginFormIDP[]; }; const formSchema = z.object({ @@ -56,7 +63,7 @@ const mfaSchema = z.object({ code: z.string().length(6, { message: "Invalid code" }) }); -export default function LoginForm({ redirect, onLogin }: LoginFormProps) { +export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { const router = useRouter(); const { env } = useEnvContext(); @@ -65,6 +72,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const hasIdp = idps && idps.length > 0; const [mfaRequested, setMfaRequested] = useState(false); @@ -207,16 +215,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
- - )} @@ -303,16 +301,47 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { )} {!mfaRequested && ( - + <> + + + {hasIdp && ( + <> +
+
+ +
+
+ + Or continue with + +
+
+ + {idps.map((idp) => ( + + ))} + + )} + )} {mfaRequested && (