mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-30 07:35:15 +02:00
show list of idp on login
This commit is contained in:
parent
b4fda6a1f6
commit
8fa719181a
9 changed files with 156 additions and 70 deletions
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue