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, idp,
idpOidcConfig, idpOidcConfig,
idpOrg, idpOrg,
orgs,
Role, Role,
roles, roles,
userOrgs, userOrgs,
@ -214,32 +215,47 @@ export async function validateOidcCallback(
); );
if (existingIdp.idp.autoProvision) { if (existingIdp.idp.autoProvision) {
const idpOrgs = await db const allOrgs = await db.select().from(orgs);
.select()
.from(idpOrg)
.where(eq(idpOrg.idpId, existingIdp.idp.idpId));
const defaultRoleMapping = existingIdp.idp.defaultRoleMapping; const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
const defaultOrgMapping = existingIdp.idp.defaultOrgMapping; const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
let userOrgInfo: { orgId: string; roleId: number }[] = []; 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; let roleId: number | undefined = undefined;
const orgMapping = idpOrg.orgMapping || defaultOrgMapping; const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping;
const hydratedOrgMapping = orgMapping const hydratedOrgMapping = hydrateOrgMapping(
?.split("{{orgId}}") orgMapping,
.join(idpOrg.orgId); org.orgId
);
if (hydratedOrgMapping) { if (hydratedOrgMapping) {
logger.debug("Hydrated Org Mapping", {
hydratedOrgMapping
});
const orgId = jmespath.search(claims, 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; continue;
} }
} }
const roleMapping = idpOrg.roleMapping || defaultRoleMapping; const roleMapping =
idpOrgRes?.roleMapping || defaultRoleMapping;
if (roleMapping) { if (roleMapping) {
logger.debug("Role Mapping", { roleMapping });
const roleName = jmespath.search(claims, roleMapping); const roleName = jmespath.search(claims, roleMapping);
if (!roleName) { if (!roleName) {
@ -254,14 +270,14 @@ export async function validateOidcCallback(
.from(roles) .from(roles)
.where( .where(
and( and(
eq(roles.orgId, idpOrg.orgId), eq(roles.orgId, org.orgId),
eq(roles.name, roleName) eq(roles.name, roleName)
) )
); );
if (!roleRes) { if (!roleRes) {
logger.error("Role not found", { logger.error("Role not found", {
orgId: idpOrg.orgId, orgId: org.orgId,
roleName roleName
}); });
continue; continue;
@ -270,7 +286,7 @@ export async function validateOidcCallback(
roleId = roleRes.roleId; roleId = roleRes.roleId;
userOrgInfo.push({ userOrgInfo.push({
orgId: idpOrg.orgId, orgId: org.orgId,
roleId 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, emailPath: data.idpOidcConfig.emailPath,
namePath: data.idpOidcConfig.namePath, namePath: data.idpOidcConfig.namePath,
scopes: data.idpOidcConfig.scopes, scopes: data.idpOidcConfig.scopes,
autoProvision: data.idpOidcConfig.autoProvision autoProvision: data.idp.autoProvision
}); });
} }
} catch (e) { } catch (e) {

View file

@ -22,7 +22,7 @@ type ValidateOidcTokenParams = {
code: string | undefined; code: string | undefined;
expectedState: string | undefined; expectedState: string | undefined;
stateCookie: string | undefined; stateCookie: string | undefined;
idp: {name: string}; idp: { name: string };
}; };
export default function ValidateOidcToken(props: ValidateOidcTokenParams) { export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
@ -64,11 +64,10 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
if (redirectUrl.startsWith("http")) { 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 { } else {
router.push(res.data.data.redirectUrl); router.push(res.data.data.redirectUrl);
} }
} catch (e) { } catch (e) {
setError(formatAxiosError(e, "Error validating OIDC token")); setError(formatAxiosError(e, "Error validating OIDC token"));
} finally { } finally {
@ -103,8 +102,12 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
<Alert variant="destructive" className="w-full"> <Alert variant="destructive" className="w-full">
<AlertCircle className="h-5 w-5" /> <AlertCircle className="h-5 w-5" />
<AlertDescription className="flex flex-col space-y-2"> <AlertDescription className="flex flex-col space-y-2">
<span>There was a problem connecting to {props.idp.name}. Please contact your administrator.</span> <span>
<span className="text-xs text-muted-foreground">{error}</span> There was a problem connecting to{" "}
{props.idp.name}. Please contact your
administrator.
</span>
<span className="text-xs">{error}</span>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}

View file

@ -8,7 +8,7 @@ import {
CardTitle CardTitle
} from "@/components/ui/card"; } from "@/components/ui/card";
import { createApiClient } from "@app/lib/api"; 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 { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
@ -17,10 +17,12 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
type DashboardLoginFormProps = { type DashboardLoginFormProps = {
redirect?: string; redirect?: string;
idps?: LoginFormIDP[];
}; };
export default function DashboardLoginForm({ export default function DashboardLoginForm({
redirect redirect,
idps
}: DashboardLoginFormProps) { }: DashboardLoginFormProps) {
const router = useRouter(); const router = useRouter();
// const api = createApiClient(useEnvContext()); // const api = createApiClient(useEnvContext());
@ -51,12 +53,15 @@ export default function DashboardLoginForm({
<h1 className="text-2xl font-bold mt-1"> <h1 className="text-2xl font-bold mt-1">
Welcome to Pangolin Welcome to Pangolin
</h1> </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> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<LoginForm <LoginForm
redirect={redirect} redirect={redirect}
idps={idps}
onLogin={() => { onLogin={() => {
if (redirect) { if (redirect) {
const safe = cleanRedirect(redirect); const safe = cleanRedirect(redirect);

View file

@ -6,6 +6,9 @@ import DashboardLoginForm from "./DashboardLoginForm";
import { Mail } from "lucide-react"; import { Mail } from "lucide-react";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect"; 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"; export const dynamic = "force-dynamic";
@ -31,6 +34,12 @@ export default async function Page(props: {
redirectUrl = cleanRedirect(searchParams.redirect as string); 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 ( return (
<> <>
{isInvite && ( {isInvite && (
@ -48,7 +57,7 @@ export default async function Page(props: {
</div> </div>
)} )}
<DashboardLoginForm redirect={redirectUrl} /> <DashboardLoginForm redirect={redirectUrl} idps={loginIdps} />
{(!signUpDisabled || isInvite) && ( {(!signUpDisabled || isInvite) && (
<p className="text-center text-muted-foreground mt-4"> <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 { Alert, AlertDescription } from "@app/components/ui/alert";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import LoginForm from "@app/components/LoginForm"; import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
import { import {
AuthWithPasswordResponse, AuthWithPasswordResponse,
AuthWithWhitelistResponse AuthWithWhitelistResponse
@ -81,6 +81,7 @@ type ResourceAuthPortalProps = {
id: number; id: number;
}; };
redirect: string; redirect: string;
idps?: LoginFormIDP[];
}; };
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
@ -490,6 +491,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
className={`${numMethods <= 1 ? "mt-0" : ""}`} className={`${numMethods <= 1 ? "mt-0" : ""}`}
> >
<LoginForm <LoginForm
idps={props.idps}
redirect={props.redirect} redirect={props.redirect}
onLogin={async () => onLogin={async () =>
await handleSSOAuth() await handleSSOAuth()

View file

@ -13,6 +13,9 @@ import ResourceNotFound from "./ResourceNotFound";
import ResourceAccessDenied from "./ResourceAccessDenied"; import ResourceAccessDenied from "./ResourceAccessDenied";
import AccessToken from "./AccessToken"; import AccessToken from "./AccessToken";
import { pullEnv } from "@app/lib/pullEnv"; 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: { export default async function ResourceAuthPage(props: {
params: Promise<{ resourceId: number }>; params: Promise<{ resourceId: number }>;
@ -84,7 +87,6 @@ export default async function ResourceAuthPage(props: {
redirect(redirectUrl); redirect(redirectUrl);
} }
// convert the dashboard token into a resource session token // convert the dashboard token into a resource session token
let userIsUnauthorized = false; let userIsUnauthorized = false;
if (user && authInfo.sso) { 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 ( return (
<> <>
{userIsUnauthorized && isSSOOnly ? ( {userIsUnauthorized && isSSOOnly ? (
@ -148,6 +156,7 @@ export default async function ResourceAuthPage(props: {
id: authInfo.resourceId id: authInfo.resourceId
}} }}
redirect={redirectUrl} redirect={redirectUrl}
idps={loginIdps}
/> />
</div> </div>
)} )}

View file

@ -99,13 +99,16 @@ export function Layout({
} }
/> />
</div> </div>
{!isAdminPage && ( {!isAdminPage &&
user.serverAdmin && (
<div className="p-4 border-t"> <div className="p-4 border-t">
<Link <Link
href="/admin" href="/admin"
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full" className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
onClick={() => onClick={() =>
setIsMobileMenuOpen(false) setIsMobileMenuOpen(
false
)
} }
> >
<Server className="h-4 w-4" /> <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"> <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} {children}
</div> </div>
</main> </main>

View file

@ -39,10 +39,17 @@ import Link from "next/link";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import Image from "next/image"; import Image from "next/image";
import { GenerateOidcUrlResponse } from "@server/routers/idp"; import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
export type LoginFormIDP = {
idpId: number;
name: string;
};
type LoginFormProps = { type LoginFormProps = {
redirect?: string; redirect?: string;
onLogin?: () => void | Promise<void>; onLogin?: () => void | Promise<void>;
idps?: LoginFormIDP[];
}; };
const formSchema = z.object({ const formSchema = z.object({
@ -56,7 +63,7 @@ const mfaSchema = z.object({
code: z.string().length(6, { message: "Invalid code" }) 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 router = useRouter();
const { env } = useEnvContext(); const { env } = useEnvContext();
@ -65,6 +72,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const hasIdp = idps && idps.length > 0;
const [mfaRequested, setMfaRequested] = useState(false); const [mfaRequested, setMfaRequested] = useState(false);
@ -207,16 +215,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
</div> </div>
</form> </form>
</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 && ( {!mfaRequested && (
<>
<Button <Button
type="submit" type="submit"
form="form" form="form"
@ -313,6 +312,36 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
<LockIcon className="w-4 h-4 mr-2" /> <LockIcon className="w-4 h-4 mr-2" />
Log In Log In
</Button> </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 && ( {mfaRequested && (