show list of idp on login

This commit is contained in:
miloschwartz 2025-04-18 17:45:59 -04:00
parent b4fda6a1f6
commit 8fa719181a
No known key found for this signature in database
9 changed files with 156 additions and 70 deletions

View file

@ -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);
}

View file

@ -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) {

View file

@ -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) {
<Alert variant="destructive" className="w-full">
<AlertCircle className="h-5 w-5" />
<AlertDescription className="flex flex-col space-y-2">
<span>There was a problem connecting to {props.idp.name}. Please contact your administrator.</span>
<span className="text-xs text-muted-foreground">{error}</span>
<span>
There was a problem connecting to{" "}
{props.idp.name}. Please contact your
administrator.
</span>
<span className="text-xs">{error}</span>
</AlertDescription>
</Alert>
)}

View file

@ -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({
<h1 className="text-2xl font-bold mt-1">
Welcome to Pangolin
</h1>
<p className="text-sm text-muted-foreground">Log in to get started</p>
<p className="text-sm text-muted-foreground">
Log in to get started
</p>
</div>
</CardHeader>
<CardContent>
<LoginForm
redirect={redirect}
idps={idps}
onLogin={() => {
if (redirect) {
const safe = cleanRedirect(redirect);

View file

@ -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: {
</div>
)}
<DashboardLoginForm redirect={redirectUrl} />
<DashboardLoginForm redirect={redirectUrl} idps={loginIdps} />
{(!signUpDisabled || isInvite) && (
<p className="text-center text-muted-foreground mt-4">

View file

@ -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" : ""}`}
>
<LoginForm
idps={props.idps}
redirect={props.redirect}
onLogin={async () =>
await handleSSOAuth()

View file

@ -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}
/>
</div>
)}

View file

@ -99,13 +99,16 @@ export function Layout({
}
/>
</div>
{!isAdminPage && (
{!isAdminPage &&
user.serverAdmin && (
<div className="p-4 border-t">
<Link
href="/admin"
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
onClick={() =>
setIsMobileMenuOpen(false)
setIsMobileMenuOpen(
false
)
}
>
<Server className="h-4 w-4" />
@ -234,7 +237,7 @@ export function Layout({
)}
>
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div className="container mx-auto max-w-12xl">
<div className="container mx-auto max-w-12xl mb-12">
{children}
</div>
</main>

View file

@ -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<void>;
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<string | null>(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) {
</div>
</form>
</Form>
<Button
type="button"
className="w-full"
onClick={() => {
loginWithIdp(1);
}}
>
OIDC Login
</Button>
</>
)}
@ -303,6 +301,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
)}
{!mfaRequested && (
<>
<Button
type="submit"
form="form"
@ -313,6 +312,36 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
<LockIcon className="w-4 h-4 mr-2" />
Log In
</Button>
{hasIdp && (
<>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="px-2 bg-card text-muted-foreground">
Or continue with
</span>
</div>
</div>
{idps.map((idp) => (
<Button
key={idp.idpId}
type="button"
variant="outline"
className="w-full"
onClick={() => {
loginWithIdp(idp.idpId);
}}
>
{idp.name}
</Button>
))}
</>
)}
</>
)}
{mfaRequested && (