Skip to content

Commit 64a133a

Browse files
[cueweb] Add CueWeb LDAP Authentication (#2096)
**Link the Issue(s) this Pull Request is related to.** - #2097 **Summarize your change** Add LDAP Authentication in CueWeb. - NextAuth Integration: Use https://next-auth.js.org/providers/credentials with https://www.npmjs.com/package/ldapjs - Security: - Encrypt LDAP connections via TLS with custom certificate support - LDAP injection protection by escaping special DN characters - Input validation for empty username/password - Configuration: Customizable Distinguished Name template - DN is specified as a string template: ```tsx dnTemplate = "uid={login},cn=users,cn=accounts,dc=company,dc=com" dn = dnTemplate.replace("{login}", "username") ``` - Update cueweb/README.md - Update documentation: - docs/_docs/other-guides/cueweb.md - https://docs.opencue.io/docs/other-guides/cueweb/ - docs/_docs/getting-started/deploying-cueweb.md - https://docs.opencue.io/docs/getting-started/deploying-cueweb/ - Add LDAP authentication screenshots to documentation: `cueweb-ldap-button.png` and `cueweb-ldap-login-password-page.png` **Why this change is important?** It can be more convenient to use the company's OpenLDAP for authentication purposes. Fully intranet and can be easier for security constraints. **Screenshots** <img width="531" height="176" alt="Capture d’écran 2025-12-09 à 00 05 57" src="https://github.com/user-attachments/assets/f65b565b-799c-4f7a-82cd-af9c0a2c8962" /> <img width="657" height="252" alt="image" src="https://github.com/user-attachments/assets/5afd3f85-6378-4eac-ab4d-8eeb3ab5b4b4" /> Co-authored-by: Alexis Oblet <[email protected]> Co-authored-by: Ramon Figueiredo <[email protected]>
1 parent 6d381df commit 64a133a

File tree

14 files changed

+527
-59
lines changed

14 files changed

+527
-59
lines changed

cueweb/.env.example

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@ SENTRY_ORG = sentryorg
88
SENTRY_PROJECT = sentryproject
99

1010
# Authentication Configuration:
11-
NEXT_PUBLIC_AUTH_PROVIDER=github,okta,google
11+
NEXT_PUBLIC_AUTH_PROVIDER=github,okta,google,ldap
1212
NEXTAUTH_URL=http://localhost:3000
1313
NEXTAUTH_SECRET=canbeanything
1414

15+
# LDAP
16+
LDAP_URI=ldaps://company.ldap:636
17+
LDAP_LOGIN_DN="uid={login},cn=users,cn=accounts,dc=tobeoverriden"
18+
LDAP_CERTIFICATE=/etc/ssl/custom.crt
19+
1520
# values from Okta OAuth 2.0
1621
NEXT_AUTH_OKTA_CLIENT_ID=oktaid
1722
NEXT_AUTH_OKTA_ISSUER=https://company.okta.com

cueweb/Dockerfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,15 @@ ENV SENTRY_PROJECT=tobeoverriden
5757
ENV SENTRY_URL=tobeoverriden
5858
ENV SENTRY_ORG=tobeoverriden
5959
ENV SENTRY_DSN=tobeoverriden
60+
ENV LDAP_LOGIN_DN=tobeoverriden
61+
ENV LDAP_CERTIFICATE=tobeoverriden
62+
ENV LDAP_URI=tobeoverriden
6063

6164
# The following environment variables need to be accurately defined during production or dev builds as their
6265
# values cannot be changed after build time
6366
ENV NEXT_PUBLIC_URL=tobeoverriden
6467
ENV NEXT_PUBLIC_OPENCUE_ENDPOINT=tobeoverriden
65-
ENV NEXT_PUBLIC_AUTH_PROVIDER=google,okta,github
68+
ENV NEXT_PUBLIC_AUTH_PROVIDER=google,okta,github,ldap
6669

6770
RUN npm run build
6871
CMD ["npm", "run", "start"]

cueweb/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,9 @@ Go back to [Contents](#contents).
225225

226226
### Configuration
227227

228-
To enable Okta, Google or Github authentication, simply set the environment variable
229-
`NEXT_PUBLIC_AUTH_PROVIDER` to either `google`, `okta`,`github` or all of them combined seperated by comma
230-
(e.g. `google,okta,github`) along with the OAuth 2.0 secrets listed in `lib/auth.ts`.
228+
To enable Okta, Google, Github or LDAP authentication, simply set the environment variable
229+
`NEXT_PUBLIC_AUTH_PROVIDER` to either `google`, `okta`, `github`, `ldap` or all of them combined separated by comma
230+
(e.g. `google,okta,github,ldap`) along with the OAuth 2.0 secrets listed in `lib/auth.ts`.
231231
For example, providing the `GOOGLE_CLIENT_ID`,
232232
`GOOGLE_CLIENT_SECRET` Google OAuth 2.0 environment variables and setting `NEXT_PUBLIC_AUTH_PROVIDER=google`
233233
will automatically enable google authentication. See `.env.example` on a list of environment variables to provide.

cueweb/app/login/ldap/page.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use client'
2+
3+
// Custom authentication page
4+
import { signIn } from "next-auth/react";
5+
import React from "react";
6+
import { useState } from "react";
7+
import { useRouter } from "next/navigation";
8+
import CueWebIcon from "@/components/ui/cuewebicon";
9+
import { handleError } from "@/app/utils/notify_utils";
10+
import { ToastContainer } from 'react-toastify';
11+
import 'react-toastify/dist/ReactToastify.css';
12+
13+
export default function Page() {
14+
const router = useRouter();
15+
const [name, setName] = useState("");
16+
const [password, setPassword] = useState("");
17+
18+
const handleSubmit = async (e: React.FormEvent) => {
19+
e.preventDefault();
20+
let res = null;
21+
22+
try {
23+
res = await signIn("credentials", {
24+
redirect: false,
25+
name,
26+
password,
27+
});
28+
} catch(error) {
29+
handleError(error, "An error occured on server side")
30+
return;
31+
}
32+
33+
if (res?.error){
34+
handleError(res?.error, "Authentication Failed")
35+
return;
36+
}
37+
38+
// Redirect on success
39+
router.push("/");
40+
};
41+
42+
43+
return (
44+
<div className="flex flex-col sm:flex-row w-full justify-center items-center h-screen bg-gray-100
45+
dark:bg-gray-800">
46+
<ToastContainer />
47+
<div className="flex flex-col sm:flex-row sm:space-x-20 max-w-[100vh] bg-white dark:bg-black sm:px-16
48+
sm:py-8 rounded-xl">
49+
<div className="flex flex-col justify-center items-center">
50+
<CueWebIcon/>
51+
</div>
52+
<form onSubmit={handleSubmit}>
53+
<div className="flex flex-col w-full space-y-3 ">
54+
<div className="mb-4">
55+
<input type="text" name="name" placeholder="Login" value={name} onChange={(e) => setName(e.target.value)} className="w-full px-3 py-2 border rounded" required />
56+
</div>
57+
<div className="mb-4">
58+
<input type="password" name="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} className="w-full px-3 py-2 border rounded" required />
59+
</div>
60+
<button type="submit" className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition" > Sign In </button>
61+
</div>
62+
</form>
63+
</div>
64+
</div>
65+
66+
);
67+
}

cueweb/app/login/page.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { signIn } from "next-auth/react";
55
import React from "react";
66
import { useRouter } from "next/navigation";
7-
import { GmailSignInButton, OktaSignInButton, GithubSignInButton, CuewebRedirectButton } from "@/components/ui/auth-button"
7+
import { GmailSignInButton, OktaSignInButton, GithubSignInButton, LdapSignInButton, CuewebRedirectButton } from "@/components/ui/auth-button"
88
import CueWebIcon from "../../components/ui/cuewebicon";
99

1010
export default function Page() {
@@ -22,6 +22,10 @@ export default function Page() {
2222
signIn("github", { callbackUrl: "/"});
2323
};
2424

25+
const ldapLogin = async () => {
26+
router.push('/login/ldap');
27+
};
28+
2529
const cuewebRedirect = () => {
2630
router.push('/');
2731
};
@@ -51,6 +55,9 @@ export default function Page() {
5155
{process.env.NEXT_PUBLIC_AUTH_PROVIDER && process.env.NEXT_PUBLIC_AUTH_PROVIDER.indexOf('github') >= 0 &&
5256
<GithubSignInButton onClick={githubLogin} />
5357
}
58+
{process.env.NEXT_PUBLIC_AUTH_PROVIDER && process.env.NEXT_PUBLIC_AUTH_PROVIDER.indexOf('ldap') >= 0 &&
59+
<LdapSignInButton onClick={ldapLogin} />
60+
}
5461
</div>
5562
</div>
5663
</div>

cueweb/app/page.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,14 @@ export default async function Page() {
2121
const session = await getServerSession(authOptions);
2222
let username = UNKNOWN_USER;
2323

24-
if (session && session.user && session.user.email) {
25-
username = session.user.email.split('@')[0];
24+
if (session && session.user) {
25+
if (session.user.email) {
26+
username = session.user.email.split('@')[0];
27+
}
28+
else if (session.user.name) {
29+
username = session.user.name;
30+
}
31+
2632
// Increment Prometheus metric - number of log ins for this user
2733
try {
2834
await fetch(`${process.env.NEXTAUTH_URL}/api/increment?username=${username}`);

cueweb/components/ui/auth-button.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Image from "next/image";
44
import React from "react";
55
import { Button } from "@/components/ui/button"
66
import oktaicon from "../../public/okta-logo.png";
7-
import { FaGithub } from "react-icons/fa"
7+
import { FaGithub, FaAddressBook } from "react-icons/fa"
88
import { FcGoogle } from "react-icons/fc"
99

1010
export function OktaSignInButton({onClick}: {onClick: ()=> void}) {
@@ -46,6 +46,19 @@ export function GithubSignInButton({onClick}: {onClick: ()=> void}) {
4646
);
4747
}
4848

49+
export function LdapSignInButton({onClick}: {onClick: ()=> void}) {
50+
return (
51+
<Button
52+
className="w-full"
53+
aria-label="Sign in with Domain Account"
54+
onClick={onClick}
55+
>
56+
<FaAddressBook className="mr-2" />
57+
LDAP
58+
</Button>
59+
);
60+
}
61+
4962
export function CuewebRedirectButton({onClick}: {onClick: ()=> void}) {
5063
return (
5164
<Button

cueweb/lib/auth.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@ import { NextAuthOptions } from "next-auth";
33
import OktaProvider from "next-auth/providers/okta";
44
import GoogleProvider from "next-auth/providers/google";
55
import GitHubProvider from "next-auth/providers/github";
6+
import CredentialsProvider from "next-auth/providers/credentials";
7+
import ldap from "ldapjs";
8+
import fs from "fs";
9+
10+
/**
11+
* Escapes special characters in LDAP DN components to prevent injection attacks.
12+
* Characters that have special meaning in DNs: , = + < > # ; \ "
13+
*/
14+
function escapeLdapDn(str: string): string {
15+
return str.replace(/[,=+<>#;\\"\x00]/g, (char) => `\\${char}`);
16+
}
617

718
const providerConfigs = [
819
{
@@ -30,6 +41,14 @@ const providerConfigs = [
3041
clientSecret: "GITHUB_SECRET",
3142
},
3243
},
44+
{
45+
type: "LDAP",
46+
envKeys: {
47+
url: "LDAP_URI",
48+
login_dn: "LDAP_LOGIN_DN",
49+
certificate: "LDAP_CERTIFICATE",
50+
}
51+
},
3352
];
3453

3554
interface Settings {
@@ -48,6 +67,58 @@ function loadProviderConfig(type: string, envKeys: any) {
4867
return settings;
4968
}
5069

70+
function buildLdapProvider(settings: Settings) {
71+
return CredentialsProvider({
72+
name: "Domain Account",
73+
credentials: {
74+
name: { label: "Login", type: "text", placeholder: "" },
75+
password: { label: "Password", type: "password" },
76+
},
77+
78+
async authorize(credentials, req) {
79+
if (!credentials || !credentials.name || !credentials.password)
80+
return null;
81+
82+
// Configure TLS options if certificate is provided
83+
let tls = {}
84+
if(settings.certificate){
85+
tls = {
86+
rejectUnauthorized: true,
87+
ca: [fs.readFileSync(settings.certificate)],
88+
}
89+
}
90+
91+
const client = ldap.createClient({
92+
url: settings.url,
93+
timeout: 5000,
94+
connectTimeout: 3000,
95+
tlsOptions: tls,
96+
})
97+
98+
99+
return new Promise((resolve, reject) => {
100+
const dn = settings.login_dn.replace("{login}", escapeLdapDn(credentials.name))
101+
client.bind(dn, credentials.password, (error: Error | null) => {
102+
client.unbind((unbindErr: Error | null) => {
103+
if (unbindErr) {
104+
console.error(`LDAP unbind error: ${unbindErr.message}`)
105+
}
106+
})
107+
if (error) {
108+
console.error(`LDAP bind failed for user: ${error.message}`)
109+
resolve(null)
110+
} else {
111+
resolve({
112+
id: credentials.name,
113+
name: credentials.name,
114+
})
115+
}
116+
})
117+
})
118+
},
119+
});
120+
}
121+
51122
const providers = providerConfigs.map(({ type, provider, envKeys }) => {
52123
const settings = loadProviderConfig(type, envKeys);
53124
if (!settings) return null;
@@ -68,6 +139,8 @@ const providers = providerConfigs.map(({ type, provider, envKeys }) => {
68139
clientId: settings.clientId,
69140
clientSecret: settings.clientSecret,
70141
});
142+
} else if (type === "LDAP") {
143+
return buildLdapProvider(settings);
71144
}
72145
return null;
73146
}).filter(provider => provider !== null) as any;
@@ -77,5 +150,3 @@ export const authOptions: NextAuthOptions = {
77150
// Additional NextAuth configurations can be added here
78151
};
79152

80-
81-

0 commit comments

Comments
 (0)