From f2c1182b180f6e9b74bf58e67a08b2c3218434d2 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 4 Feb 2025 13:11:04 +0900 Subject: [PATCH 01/47] feat(auth): add NotAuthorized and NotFound pages --- src/ui/views/Extras/NotAuthorized.jsx | 39 +++++++++++++++++++++++++++ src/ui/views/Extras/NotFound.jsx | 36 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/ui/views/Extras/NotAuthorized.jsx create mode 100644 src/ui/views/Extras/NotFound.jsx diff --git a/src/ui/views/Extras/NotAuthorized.jsx b/src/ui/views/Extras/NotAuthorized.jsx new file mode 100644 index 000000000..f08c478b1 --- /dev/null +++ b/src/ui/views/Extras/NotAuthorized.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import Card from '../../components/Card/Card'; +import CardBody from '../../components/Card/CardBody'; +import GridContainer from '../../components/Grid/GridContainer'; +import GridItem from '../../components/Grid/GridItem'; +import { Button } from '@material-ui/core'; +import LockIcon from '@material-ui/icons/Lock'; + +const NotAuthorized = () => { + const navigate = useNavigate(); + + return ( + + + + + +

403 - Not Authorized

+

+ You do not have permission to access this page. Contact your administrator for more + information, or try logging in with a different account. +

+ +
+
+
+
+ ); +}; + +export default NotAuthorized; diff --git a/src/ui/views/Extras/NotFound.jsx b/src/ui/views/Extras/NotFound.jsx new file mode 100644 index 000000000..d548200de --- /dev/null +++ b/src/ui/views/Extras/NotFound.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import Card from '../../components/Card/Card'; +import CardBody from '../../components/Card/CardBody'; +import GridContainer from '../../components/Grid/GridContainer'; +import GridItem from '../../components/Grid/GridItem'; +import { Button } from '@material-ui/core'; +import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'; + +const NotFound = () => { + const navigate = useNavigate(); + + return ( + + + + + +

404 - Page Not Found

+

The page you are looking for does not exist. It may have been moved or deleted.

+ +
+
+
+
+ ); +}; + +export default NotFound; From 256523f4a0704c5cd10cac0f0014aa5ffdf8aa61 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 4 Feb 2025 13:56:32 +0900 Subject: [PATCH 02/47] feat(auth): add AuthProvider and PrivateRoute wrapper --- src/ui/auth/AuthProvider.tsx | 49 +++++++++++++++++++ .../components/PrivateRoute/PrivateRoute.tsx | 27 ++++++++++ 2 files changed, 76 insertions(+) create mode 100644 src/ui/auth/AuthProvider.tsx create mode 100644 src/ui/components/PrivateRoute/PrivateRoute.tsx diff --git a/src/ui/auth/AuthProvider.tsx b/src/ui/auth/AuthProvider.tsx new file mode 100644 index 000000000..1da89df51 --- /dev/null +++ b/src/ui/auth/AuthProvider.tsx @@ -0,0 +1,49 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { getUserInfo } from '../services/auth'; + +// Interface for when we convert to TypeScript +// interface AuthContextType { +// user: any; +// setUser: (user: any) => void; +// refreshUser: () => Promise; +// isLoading: boolean; +// } + +const AuthContext = createContext(undefined); + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const refreshUser = async () => { + console.log('Refreshing user'); + try { + const data = await getUserInfo(); + setUser(data); + console.log('User refreshed:', data); + } catch (error) { + console.error('Error refreshing user:', error); + setUser(null); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + refreshUser(); + }, []); + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/src/ui/components/PrivateRoute/PrivateRoute.tsx b/src/ui/components/PrivateRoute/PrivateRoute.tsx new file mode 100644 index 000000000..05da922ce --- /dev/null +++ b/src/ui/components/PrivateRoute/PrivateRoute.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../../auth/AuthProvider'; + +const PrivateRoute = ({ component: Component, adminOnly = false }) => { + const { user, isLoading } = useAuth(); + console.debug('PrivateRoute', { user, isLoading, adminOnly }); + + if (isLoading) { + console.debug('Auth is loading, waiting'); + return
Loading...
; // TODO: Add loading spinner + } + + if (!user) { + console.debug('User not logged in, redirecting to login page'); + return ; + } + + if (adminOnly && !user.isAdmin) { + console.debug('User is not an admin, redirecting to not authorized page'); + return ; + } + + return ; +}; + +export default PrivateRoute; From 516ed7fe5fb81656af9387aac09a00651d1c2883 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 4 Feb 2025 14:02:55 +0900 Subject: [PATCH 03/47] chore(auth): rename userLoggedIn endpoint --- src/service/routes/auth.js | 2 +- src/ui/services/user.js | 2 +- test/testLogin.test.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/service/routes/auth.js b/src/service/routes/auth.js index d79855905..744b17798 100644 --- a/src/service/routes/auth.js +++ b/src/service/routes/auth.js @@ -142,7 +142,7 @@ router.post('/gitAccount', async (req, res) => { } }); -router.get('/userLoggedIn', async (req, res) => { +router.get('/me', async (req, res) => { if (req.user) { const user = JSON.parse(JSON.stringify(req.user)); if (user && user.password) delete user.password; diff --git a/src/ui/services/user.js b/src/ui/services/user.js index 04a2fdccb..cab1dc3ea 100644 --- a/src/ui/services/user.js +++ b/src/ui/services/user.js @@ -77,7 +77,7 @@ const updateUser = async (data) => { }; const getUserLoggedIn = async (setIsLoading, setIsAdmin, setIsError, setAuth) => { - const url = new URL(`${baseUrl}/api/auth/userLoggedIn`); + const url = new URL(`${baseUrl}/api/auth/me`); await axios(url.toString(), { withCredentials: true }) .then((response) => { diff --git a/test/testLogin.test.js b/test/testLogin.test.js index 812e4f755..833184e0b 100644 --- a/test/testLogin.test.js +++ b/test/testLogin.test.js @@ -43,7 +43,7 @@ describe('auth', async () => { }); it('should now be able to access the user login metadata', async function () { - const res = await chai.request(app).get('/api/auth/userLoggedIn').set('Cookie', `${cookie}`); + const res = await chai.request(app).get('/api/auth/me').set('Cookie', `${cookie}`); res.should.have.status(200); }); From 47e95d440d2fd410d28228a874ea701577814e62 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 4 Feb 2025 14:08:54 +0900 Subject: [PATCH 04/47] chore(auth): refactor routes to use PrivateRoute guard --- src/index.jsx | 21 ++++++++++++++------- src/routes.jsx | 42 +++++++++++++++++++++-------------------- src/ui/services/auth.js | 22 +++++++++++++++++++++ 3 files changed, 58 insertions(+), 27 deletions(-) create mode 100644 src/ui/services/auth.js diff --git a/src/index.jsx b/src/index.jsx index 4aca4983b..316d7dda2 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -2,21 +2,28 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { createBrowserHistory } from 'history'; import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom'; +import { AuthProvider } from './ui/auth/AuthProvider'; // core components import Admin from './ui/layouts/Admin'; import Login from './ui/views/Login/Login'; import './ui/assets/css/material-dashboard-react.css'; +import NotAuthorized from './ui/views/Extras/NotAuthorized'; +import NotFound from './ui/views/Extras/NotFound'; const hist = createBrowserHistory(); ReactDOM.render( - - - } /> - } /> - } /> - - , + + + + } /> + } /> + } /> + } /> + } /> + + + , document.getElementById('root'), ); diff --git a/src/routes.jsx b/src/routes.jsx index 526b452aa..ed12a7241 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -16,6 +16,8 @@ */ +import React from 'react'; +import PrivateRoute from './ui/components/PrivateRoute/PrivateRoute'; import Person from '@material-ui/icons/Person'; import OpenPushRequests from './ui/views/OpenPushRequests/OpenPushRequests'; import PushDetails from './ui/views/PushDetails/PushDetails'; @@ -33,15 +35,23 @@ const dashboardRoutes = [ path: '/repo', name: 'Repositories', icon: RepoIcon, - component: RepoList, + component: (props) => , layout: '/admin', visible: true, }, + { + path: '/repo/:id', + name: 'Repo Details', + icon: Person, + component: (props) => , + layout: '/admin', + visible: false, + }, { path: '/push', name: 'Dashboard', icon: Dashboard, - component: OpenPushRequests, + component: (props) => , layout: '/admin', visible: true, }, @@ -49,7 +59,7 @@ const dashboardRoutes = [ path: '/push/:id', name: 'Open Push Requests', icon: Person, - component: PushDetails, + component: (props) => , layout: '/admin', visible: false, }, @@ -57,34 +67,26 @@ const dashboardRoutes = [ path: '/profile', name: 'My Account', icon: AccountCircle, - component: User, + component: (props) => , layout: '/admin', visible: true, }, { - path: '/user/:id', - name: 'User', - icon: Person, - component: User, + path: '/user', + name: 'Users', + icon: Group, + component: (props) => , layout: '/admin', - visible: false, + visible: true, }, { - path: '/repo/:id', - name: 'Repo Details', + path: '/user/:id', + name: 'User', icon: Person, - component: RepoDetails, + component: (props) => , layout: '/admin', visible: false, }, - { - path: '/user', - name: 'Users', - icon: Group, - component: UserList, - layout: '/admin', - visible: true, - }, ]; export default dashboardRoutes; diff --git a/src/ui/services/auth.js b/src/ui/services/auth.js new file mode 100644 index 000000000..e1155e9f5 --- /dev/null +++ b/src/ui/services/auth.js @@ -0,0 +1,22 @@ +const baseUrl = import.meta.env.VITE_API_URI + ? `${import.meta.env.VITE_API_URI}` + : `${location.origin}`; + +/** + * Gets the current user's information + * @return {Promise} The user's information + */ +export const getUserInfo = async () => { + try { + const response = await fetch(`${baseUrl}/api/auth/me`, { + credentials: 'include', // Sends cookies + }); + + if (!response.ok) throw new Error(`Failed to fetch user info: ${response.statusText}`); + + return await response.json(); + } catch (error) { + console.error('Error fetching user info:', error); + return null; + } +}; From 893e0b132fd419ac35881103231df57005865b58 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 7 Feb 2025 16:05:35 +0900 Subject: [PATCH 05/47] fix(auth): temporary fix for username edit redirect --- src/ui/views/User/User.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/views/User/User.jsx b/src/ui/views/User/User.jsx index c8b46ebe5..e78bf9d89 100644 --- a/src/ui/views/User/User.jsx +++ b/src/ui/views/User/User.jsx @@ -60,7 +60,7 @@ export default function Dashboard() { try { data.gitAccount = escapeHTML(gitAccount); await updateUser(data); - navigate(`/admin/user/${data.username}`); + navigate(`/admin/profile`); } catch { setIsError(true); } From d049463d403d6a94a2b70c6116e36bc13cdc623f Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 7 Feb 2025 16:14:08 +0900 Subject: [PATCH 06/47] fix(auth): access user admin status correctly --- src/ui/components/PrivateRoute/PrivateRoute.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/components/PrivateRoute/PrivateRoute.tsx b/src/ui/components/PrivateRoute/PrivateRoute.tsx index 05da922ce..4e7a2f4bf 100644 --- a/src/ui/components/PrivateRoute/PrivateRoute.tsx +++ b/src/ui/components/PrivateRoute/PrivateRoute.tsx @@ -16,7 +16,7 @@ const PrivateRoute = ({ component: Component, adminOnly = false }) => { return ; } - if (adminOnly && !user.isAdmin) { + if (adminOnly && !user.admin) { console.debug('User is not an admin, redirecting to not authorized page'); return ; } From db04af6d2bd41371a218c9e0b8c6f72cd5b201b4 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 11 Feb 2025 12:46:24 +0900 Subject: [PATCH 07/47] chore(auth): refactor /admin routes into /dashboard --- cypress/e2e/repo.cy.js | 4 ++-- src/index.jsx | 6 +++--- .../processors/push-action/blockForAuth.js | 2 +- src/routes.jsx | 18 +++++++++--------- src/service/routes/auth.js | 2 +- src/ui/components/Sidebar/Sidebar.jsx | 2 +- src/ui/views/Login/Login.jsx | 8 ++++---- .../components/PushesTable.jsx | 2 +- src/ui/views/PushDetails/PushDetails.jsx | 10 +++++----- .../PushDetails/components/AttestationView.jsx | 4 ++-- src/ui/views/RepoDetails/RepoDetails.jsx | 6 +++--- .../views/RepoList/Components/RepoOverview.jsx | 2 +- .../views/RepoList/Components/Repositories.jsx | 2 +- src/ui/views/User/User.jsx | 4 ++-- src/ui/views/UserList/Components/UserList.jsx | 2 +- 15 files changed, 37 insertions(+), 37 deletions(-) diff --git a/cypress/e2e/repo.cy.js b/cypress/e2e/repo.cy.js index 32c7d1cab..ea4bbce43 100644 --- a/cypress/e2e/repo.cy.js +++ b/cypress/e2e/repo.cy.js @@ -1,6 +1,6 @@ describe('Repo', () => { beforeEach(() => { - cy.visit('/admin/repo'); + cy.visit('/dashboard/repo'); // prevent failures on 404 request and uncaught promises cy.on('uncaught:exception', () => false); @@ -18,7 +18,7 @@ describe('Repo', () => { cy // find the entry for finos/test-repo - .get('a[href="/admin/repo/test-repo"]') + .get('a[href="/dashboard/repo/test-repo"]') // take it's parent row .closest('tr') // find the nearby span containing Code we can click to open the tooltip diff --git a/src/index.jsx b/src/index.jsx index 316d7dda2..04505ff5f 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -5,7 +5,7 @@ import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-d import { AuthProvider } from './ui/auth/AuthProvider'; // core components -import Admin from './ui/layouts/Admin'; +import Dashboard from './ui/layouts/Dashboard'; import Login from './ui/views/Login/Login'; import './ui/assets/css/material-dashboard-react.css'; import NotAuthorized from './ui/views/Extras/NotAuthorized'; @@ -17,10 +17,10 @@ ReactDOM.render( - } /> + } /> } /> } /> - } /> + } /> } /> diff --git a/src/proxy/processors/push-action/blockForAuth.js b/src/proxy/processors/push-action/blockForAuth.js index cc99d6b92..5b0b2db56 100644 --- a/src/proxy/processors/push-action/blockForAuth.js +++ b/src/proxy/processors/push-action/blockForAuth.js @@ -10,7 +10,7 @@ const exec = async (req, action) => { '\n\n\n' + `\x1B[32mGitProxy has received your push ✅\x1B[0m\n\n` + '🔗 Shareable Link\n\n' + - `\x1B[34m${url}/admin/push/${action.id}\x1B[0m` + + `\x1B[34m${url}/dashboard/push/${action.id}\x1B[0m` + '\n\n\n'; step.setAsyncBlock(message); diff --git a/src/routes.jsx b/src/routes.jsx index ed12a7241..a1204b735 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -36,7 +36,7 @@ const dashboardRoutes = [ name: 'Repositories', icon: RepoIcon, component: (props) => , - layout: '/admin', + layout: '/dashboard', visible: true, }, { @@ -44,7 +44,7 @@ const dashboardRoutes = [ name: 'Repo Details', icon: Person, component: (props) => , - layout: '/admin', + layout: '/dashboard', visible: false, }, { @@ -52,7 +52,7 @@ const dashboardRoutes = [ name: 'Dashboard', icon: Dashboard, component: (props) => , - layout: '/admin', + layout: '/dashboard', visible: true, }, { @@ -60,7 +60,7 @@ const dashboardRoutes = [ name: 'Open Push Requests', icon: Person, component: (props) => , - layout: '/admin', + layout: '/dashboard', visible: false, }, { @@ -68,23 +68,23 @@ const dashboardRoutes = [ name: 'My Account', icon: AccountCircle, component: (props) => , - layout: '/admin', + layout: '/dashboard', visible: true, }, { - path: '/user', + path: '/admin/user', name: 'Users', icon: Group, component: (props) => , - layout: '/admin', + layout: '/dashboard', visible: true, }, { - path: '/user/:id', + path: '/admin/user/:id', name: 'User', icon: Person, component: (props) => , - layout: '/admin', + layout: '/dashboard', visible: false, }, ]; diff --git a/src/service/routes/auth.js b/src/service/routes/auth.js index 744b17798..79103e305 100644 --- a/src/service/routes/auth.js +++ b/src/service/routes/auth.js @@ -67,7 +67,7 @@ router.get('/oidc/callback', (req, res, next) => { return res.status(401).end(); } console.log('Logged in successfully. User:', user); - return res.redirect(`${uiHost}:${uiPort}/admin/profile`); + return res.redirect(`${uiHost}:${uiPort}/dashboard/profile`); }); })(req, res, next); }); diff --git a/src/ui/components/Sidebar/Sidebar.jsx b/src/ui/components/Sidebar/Sidebar.jsx index 174e31a4e..2ebef0fa5 100644 --- a/src/ui/components/Sidebar/Sidebar.jsx +++ b/src/ui/components/Sidebar/Sidebar.jsx @@ -73,7 +73,7 @@ export default function Sidebar(props) { ); const brand = (
- +
; + return ; } if (success) { - return ; + return ; } return ( @@ -169,8 +169,8 @@ export default function UserProfile() { diff --git a/src/ui/views/OpenPushRequests/components/PushesTable.jsx b/src/ui/views/OpenPushRequests/components/PushesTable.jsx index a01f169eb..a39c4f451 100644 --- a/src/ui/views/OpenPushRequests/components/PushesTable.jsx +++ b/src/ui/views/OpenPushRequests/components/PushesTable.jsx @@ -23,7 +23,7 @@ export default function PushesTable(props) { const [isError, setIsError] = useState(false); const navigate = useNavigate(); - const openPush = (push) => navigate(`/admin/push/${push}`, { replace: true }); + const openPush = (push) => navigate(`/dashboard/push/${push}`, { replace: true }); useEffect(() => { const query = {}; diff --git a/src/ui/views/PushDetails/PushDetails.jsx b/src/ui/views/PushDetails/PushDetails.jsx index 6f02d717b..dda54f76e 100644 --- a/src/ui/views/PushDetails/PushDetails.jsx +++ b/src/ui/views/PushDetails/PushDetails.jsx @@ -48,20 +48,20 @@ export default function Dashboard() { const authorise = async (attestationData) => { await authorisePush(id, setMessage, setUserAllowedToApprove, attestationData); if (isUserAllowedToApprove) { - navigate('/admin/push/'); + navigate('/dashboard/push/'); } }; const reject = async () => { await rejectPush(id, setMessage, setUserAllowedToReject); if (isUserAllowedToReject) { - navigate('/admin/push/'); + navigate('/dashboard/push/'); } }; const cancel = async () => { await cancelPush(id, setAuth, setIsError); - navigate(`/admin/push/`); + navigate(`/dashboard/push/`); }; if (isLoading) return
Loading...
; @@ -178,7 +178,7 @@ export default function Dashboard() { htmlColor='green' /> - +

- + {data.attestation.reviewer.gitAccount} {' '} approved this contribution diff --git a/src/ui/views/PushDetails/components/AttestationView.jsx b/src/ui/views/PushDetails/components/AttestationView.jsx index 70540ca76..9ccbfc8a8 100644 --- a/src/ui/views/PushDetails/components/AttestationView.jsx +++ b/src/ui/views/PushDetails/components/AttestationView.jsx @@ -62,7 +62,7 @@ export default function AttestationView(props) {

Prior to making this code contribution publicly accessible via GitHub, this code contribution was reviewed and approved by{' '} - + {props.data.reviewer.gitAccount} . As a reviewer, it was their responsibility to confirm that open sourcing this @@ -72,7 +72,7 @@ export default function AttestationView(props) {

- + {props.data.reviewer.gitAccount} {' '} approved this contribution{' '} diff --git a/src/ui/views/RepoDetails/RepoDetails.jsx b/src/ui/views/RepoDetails/RepoDetails.jsx index 9c91c1b68..56d80e278 100644 --- a/src/ui/views/RepoDetails/RepoDetails.jsx +++ b/src/ui/views/RepoDetails/RepoDetails.jsx @@ -51,7 +51,7 @@ export default function RepoDetails() { const removeRepository = async (name) => { await deleteRepo(name); - navigate('/admin/repo', { replace: true }); + navigate('/dashboard/repo', { replace: true }); }; const refresh = () => getRepo(setIsLoading, setData, setAuth, setIsError, repoName); @@ -151,7 +151,7 @@ export default function RepoDetails() { return ( - {row} + {row} {user.admin && ( @@ -196,7 +196,7 @@ export default function RepoDetails() { return ( - {row} + {row} {user.admin && ( diff --git a/src/ui/views/RepoList/Components/RepoOverview.jsx b/src/ui/views/RepoList/Components/RepoOverview.jsx index a431dc721..f35c85e15 100644 --- a/src/ui/views/RepoList/Components/RepoOverview.jsx +++ b/src/ui/views/RepoList/Components/RepoOverview.jsx @@ -591,7 +591,7 @@ export default function Repositories(props) {

- + {props.data.project}/{props.data.name} diff --git a/src/ui/views/RepoList/Components/Repositories.jsx b/src/ui/views/RepoList/Components/Repositories.jsx index 4970858ec..543f7df10 100644 --- a/src/ui/views/RepoList/Components/Repositories.jsx +++ b/src/ui/views/RepoList/Components/Repositories.jsx @@ -21,7 +21,7 @@ export default function Repositories(props) { const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); const navigate = useNavigate(); - const openRepo = (repo) => navigate(`/admin/repo/${repo}`, { replace: true }); + const openRepo = (repo) => navigate(`/dashboard/repo/${repo}`, { replace: true }); const { user } = useContext(UserContext); useEffect(() => { diff --git a/src/ui/views/User/User.jsx b/src/ui/views/User/User.jsx index e78bf9d89..44fa64e1c 100644 --- a/src/ui/views/User/User.jsx +++ b/src/ui/views/User/User.jsx @@ -52,7 +52,7 @@ export default function Dashboard() { if (isLoading) return
Loading...
; if (isError) return
Something went wrong ...
; - if (!auth && window.location.pathname === '/admin/profile') { + if (!auth && window.location.pathname === '/dashboard/profile') { return ; } @@ -60,7 +60,7 @@ export default function Dashboard() { try { data.gitAccount = escapeHTML(gitAccount); await updateUser(data); - navigate(`/admin/profile`); + navigate(`/dashboard/profile`); } catch { setIsError(true); } diff --git a/src/ui/views/UserList/Components/UserList.jsx b/src/ui/views/UserList/Components/UserList.jsx index b1273cb58..988431125 100644 --- a/src/ui/views/UserList/Components/UserList.jsx +++ b/src/ui/views/UserList/Components/UserList.jsx @@ -25,7 +25,7 @@ export default function UserList(props) { const [isError, setIsError] = useState(false); const navigate = useNavigate(); - const openUser = (username) => navigate(`/admin/user/${username}`, { replace: true }); + const openUser = (username) => navigate(`/dashboard/admin/user/${username}`, { replace: true }); useEffect(() => { const query = {}; From 9736801f4c947d5ecc552d78cecc5dd1bdefb6eb Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 11 Feb 2025 12:48:29 +0900 Subject: [PATCH 08/47] chore(auth): rename Admin files into Dashboard --- .../layouts/{adminStyle.js => dashboardStyle.js} | 0 .../{AdminNavbarLinks.jsx => DashboardNavbarLinks.jsx} | 4 ++-- src/ui/components/Navbars/Navbar.jsx | 4 ++-- src/ui/layouts/{Admin.jsx => Dashboard.jsx} | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) rename src/ui/assets/jss/material-dashboard-react/layouts/{adminStyle.js => dashboardStyle.js} (100%) rename src/ui/components/Navbars/{AdminNavbarLinks.jsx => DashboardNavbarLinks.jsx} (97%) rename src/ui/layouts/{Admin.jsx => Dashboard.jsx} (93%) diff --git a/src/ui/assets/jss/material-dashboard-react/layouts/adminStyle.js b/src/ui/assets/jss/material-dashboard-react/layouts/dashboardStyle.js similarity index 100% rename from src/ui/assets/jss/material-dashboard-react/layouts/adminStyle.js rename to src/ui/assets/jss/material-dashboard-react/layouts/dashboardStyle.js diff --git a/src/ui/components/Navbars/AdminNavbarLinks.jsx b/src/ui/components/Navbars/DashboardNavbarLinks.jsx similarity index 97% rename from src/ui/components/Navbars/AdminNavbarLinks.jsx rename to src/ui/components/Navbars/DashboardNavbarLinks.jsx index 1821f52c1..a3e6e4177 100644 --- a/src/ui/components/Navbars/AdminNavbarLinks.jsx +++ b/src/ui/components/Navbars/DashboardNavbarLinks.jsx @@ -19,7 +19,7 @@ import { getCookie } from '../../utils'; const useStyles = makeStyles(styles); -export default function AdminNavbarLinks() { +export default function DashboardNavbarLinks() { const classes = useStyles(); const navigate = useNavigate(); const [openProfile, setOpenProfile] = React.useState(null); @@ -44,7 +44,7 @@ export default function AdminNavbarLinks() { }; const showProfile = () => { - navigate('/admin/profile', { replace: true }); + navigate('/dashboard/profile', { replace: true }); }; const logout = () => { diff --git a/src/ui/components/Navbars/Navbar.jsx b/src/ui/components/Navbars/Navbar.jsx index e3925bc8f..44fd6bd08 100644 --- a/src/ui/components/Navbars/Navbar.jsx +++ b/src/ui/components/Navbars/Navbar.jsx @@ -7,7 +7,7 @@ import Toolbar from '@material-ui/core/Toolbar'; import IconButton from '@material-ui/core/IconButton'; import Hidden from '@material-ui/core/Hidden'; import Menu from '@material-ui/icons/Menu'; -import AdminNavbarLinks from './AdminNavbarLinks'; +import DashboardNavbarLinks from './DashboardNavbarLinks'; import styles from '../../assets/jss/material-dashboard-react/components/headerStyle'; const useStyles = makeStyles(styles); @@ -42,7 +42,7 @@ export default function Header(props) {
- + diff --git a/src/ui/layouts/Admin.jsx b/src/ui/layouts/Dashboard.jsx similarity index 93% rename from src/ui/layouts/Admin.jsx rename to src/ui/layouts/Dashboard.jsx index b08c6bab0..abcc053af 100644 --- a/src/ui/layouts/Admin.jsx +++ b/src/ui/layouts/Dashboard.jsx @@ -7,7 +7,7 @@ import Navbar from '../components/Navbars/Navbar'; import Footer from '../components/Footer/Footer'; import Sidebar from '../components/Sidebar/Sidebar'; import routes from '../../routes'; -import styles from '../assets/jss/material-dashboard-react/layouts/adminStyle'; +import styles from '../assets/jss/material-dashboard-react/layouts/dashboardStyle'; import logo from '../assets/img/git-proxy.png'; import { UserContext } from '../../context'; import { getUser } from '../services/user'; @@ -18,18 +18,18 @@ let refresh = false; const switchRoutes = ( {routes.map((prop, key) => { - if (prop.layout === '/admin') { + if (prop.layout === '/dashboard') { return } key={key} />; } return null; })} - } /> + } /> ); const useStyles = makeStyles(styles); -export default function Admin({ ...rest }) { +export default function Dashboard({ ...rest }) { // styles const classes = useStyles(); // ref to help us initialize PerfectScrollbar on windows devices @@ -43,7 +43,7 @@ export default function Admin({ ...rest }) { setMobileOpen(!mobileOpen); }; const getRoute = () => { - return window.location.pathname !== '/admin/maps'; + return window.location.pathname !== '/dashboard/maps'; }; const resizeFunction = () => { if (window.innerWidth >= 960) { From e7ec1f0bd3843b54772acfe9d84efb7115d22592 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 11 Feb 2025 13:35:56 +0900 Subject: [PATCH 09/47] test(auth): Add default login redirect E2E test --- cypress/e2e/login.cy.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cypress/e2e/login.cy.js b/cypress/e2e/login.cy.js index 590506f62..16da32f83 100644 --- a/cypress/e2e/login.cy.js +++ b/cypress/e2e/login.cy.js @@ -19,6 +19,18 @@ describe('Login page', () => { cy.get('[data-test="login"]').should('exist'); }); + it('should redirect to repo list on valid login', () => { + cy.intercept('GET', '**/api/auth/me').as('getUser'); + + cy.get('[data-test="username"]').type('admin'); + cy.get('[data-test="password"]').type('admin'); + cy.get('[data-test="login"]').click(); + + cy.wait('@getUser'); + + cy.url().should('include', '/dashboard/repo'); + }) + describe('OIDC login button', () => { it('should exist', () => { cy.get('[data-test="oidc-login"]').should('exist'); From 3702acde201d39bdf73d1af0e0a84113a33b94b6 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 11 Feb 2025 13:39:00 +0900 Subject: [PATCH 10/47] fix(auth): fix redirect on local login --- src/ui/views/Login/Login.jsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/ui/views/Login/Login.jsx b/src/ui/views/Login/Login.jsx index 2bd697133..0be3781a6 100644 --- a/src/ui/views/Login/Login.jsx +++ b/src/ui/views/Login/Login.jsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; // @material-ui/core components import FormControl from '@material-ui/core/FormControl'; import InputLabel from '@material-ui/core/InputLabel'; @@ -12,10 +13,10 @@ import CardHeader from '../../components/Card/CardHeader'; import CardBody from '../../components/Card/CardBody'; import CardFooter from '../../components/Card/CardFooter'; import axios from 'axios'; -import { Navigate } from 'react-router-dom'; import logo from '../../assets/img/git-proxy.png'; import { Badge, CircularProgress, Snackbar } from '@material-ui/core'; import { getCookie } from '../../utils'; +import { useAuth } from '../../auth/AuthProvider'; const loginUrl = `${import.meta.env.VITE_API_URI}/api/auth/login`; @@ -27,6 +28,9 @@ export default function UserProfile() { const [gitAccountError, setGitAccountError] = useState(false); const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + const { refreshUser } = useAuth(); + function validateForm() { return ( username.length > 0 && username.length < 100 && password.length > 0 && password.length < 200 @@ -57,8 +61,8 @@ export default function UserProfile() { .then(function () { window.sessionStorage.setItem('git.proxy.login', 'success'); setMessage('Success!'); - setSuccess(true); setIsLoading(false); + refreshUser().then(() => navigate('/dashboard/repo')); }) .catch(function (error) { if (error.response.status === 307) { @@ -75,13 +79,6 @@ export default function UserProfile() { event.preventDefault(); } - if (gitAccountError) { - return ; - } - if (success) { - return ; - } - return (
Date: Tue, 11 Feb 2025 13:39:55 +0900 Subject: [PATCH 11/47] fix(auth): improve error handling on repos page --- src/ui/views/RepoList/Components/RepoOverview.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/views/RepoList/Components/RepoOverview.jsx b/src/ui/views/RepoList/Components/RepoOverview.jsx index f35c85e15..9e98886df 100644 --- a/src/ui/views/RepoList/Components/RepoOverview.jsx +++ b/src/ui/views/RepoList/Components/RepoOverview.jsx @@ -581,6 +581,9 @@ export default function Repositories(props) { .get(`https://api.github.com/repos/${props.data.project}/${props.data.name}`) .then((res) => { setGitHub(res.data); + }) + .catch((err) => { + console.error(`Error fetching GitHub repository ${props.data.project}/${props.data.name}: ${err}`); }); }; From 24ca06f2f558b8072080244ffd2d0b442c132600 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 11 Feb 2025 13:51:41 +0900 Subject: [PATCH 12/47] test(auth): fix failing test (login before accessing page) --- cypress/e2e/repo.cy.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cypress/e2e/repo.cy.js b/cypress/e2e/repo.cy.js index ea4bbce43..d27bf09c2 100644 --- a/cypress/e2e/repo.cy.js +++ b/cypress/e2e/repo.cy.js @@ -1,5 +1,16 @@ describe('Repo', () => { beforeEach(() => { + // Log in with default admin user + cy.visit('/login'); + + cy.intercept('GET', '**/api/auth/me').as('getUser'); + + cy.get('[data-test="username"]').type('admin'); + cy.get('[data-test="password"]').type('admin'); + cy.get('[data-test="login"]').click(); + + cy.wait('@getUser'); + cy.visit('/dashboard/repo'); // prevent failures on 404 request and uncaught promises From 2146bc0a8a430509904a2e9988a4a519190a8900 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 11 Feb 2025 14:28:16 +0900 Subject: [PATCH 13/47] test(auth): fix login command and simplify login flow --- cypress/e2e/repo.cy.js | 11 +---------- cypress/support/commands.js | 6 +++++- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/cypress/e2e/repo.cy.js b/cypress/e2e/repo.cy.js index d27bf09c2..411397128 100644 --- a/cypress/e2e/repo.cy.js +++ b/cypress/e2e/repo.cy.js @@ -1,15 +1,6 @@ describe('Repo', () => { beforeEach(() => { - // Log in with default admin user - cy.visit('/login'); - - cy.intercept('GET', '**/api/auth/me').as('getUser'); - - cy.get('[data-test="username"]').type('admin'); - cy.get('[data-test="password"]').type('admin'); - cy.get('[data-test="login"]').click(); - - cy.wait('@getUser'); + cy.login('admin', 'admin'); cy.visit('/dashboard/repo'); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index aa3b052c2..751eabdfa 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -29,9 +29,13 @@ Cypress.Commands.add('login', (username, password) => { cy.session([username, password], () => { cy.visit('/login'); + cy.intercept('GET', '**/api/auth/me').as('getUser'); + cy.get('[data-test=username]').type(username); cy.get('[data-test=password]').type(password); cy.get('[data-test=login]').click(); - cy.url().should('contain', '/admin/profile'); + + cy.wait('@getUser'); + cy.url().should('include', '/dashboard/repo'); }); }); From 884f0a60f52b959cf7e6dbb05160b32e61aab0ec Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 16 Feb 2025 16:31:43 +0900 Subject: [PATCH 14/47] feat(auth): refactor and improve existing auth methods --- src/service/passport/activeDirectory.js | 90 ++++++++++++++----------- src/service/passport/local.js | 80 +++++++++++----------- src/service/passport/oidc.js | 34 +++++----- src/service/routes/auth.js | 4 +- 4 files changed, 109 insertions(+), 99 deletions(-) diff --git a/src/service/passport/activeDirectory.js b/src/service/passport/activeDirectory.js index 466f57b16..372868133 100644 --- a/src/service/passport/activeDirectory.js +++ b/src/service/passport/activeDirectory.js @@ -1,16 +1,19 @@ -const configure = () => { - const passport = require('passport'); - const ActiveDirectoryStrategy = require('passport-activedirectory'); - const config = require('../../config').getAuthentication(); - const adConfig = config.adConfig; +const ActiveDirectoryStrategy = require('passport-activedirectory'); +const ldaphelper = require('./ldaphelper'); + +const configure = (passport) => { const db = require('../../db'); - const userGroup = config.userGroup; - const adminGroup = config.adminGroup; - const domain = config.domain; + + // We can refactor this by normalizing auth strategy config and pass it directly into the configure() function, + // ideally when we convert this to TS. + const authMethods = require('../../config').getAuthMethods(); + const config = authMethods.find((method) => method.type.toLowerCase() === "activeDirectory"); + const adConfig = config.adConfig; + + const { userGroup, adminGroup, domain } = config; console.log(`AD User Group: ${userGroup}, AD Admin Group: ${adminGroup}`); - const ldaphelper = require('./ldaphelper'); passport.use( new ActiveDirectoryStrategy( { @@ -19,42 +22,47 @@ const configure = () => { ldap: adConfig, }, async function (req, profile, ad, done) { - profile.username = profile._json.sAMAccountName.toLowerCase(); - profile.email = profile._json.mail; - profile.id = profile.username; - req.user = profile; - - console.log( - `passport.activeDirectory: resolved login ${ - profile._json.userPrincipalName - }, profile=${JSON.stringify(profile)}`, - ); - // First check to see if the user is in the usergroups - const isUser = await ldaphelper.isUserInAdGroup(profile.username, domain, userGroup); - - if (!isUser) { - const message = `User it not a member of ${userGroup}`; - return done(message, null); - } + try { + profile.username = profile._json.sAMAccountName?.toLowerCase(); + profile.email = profile._json.mail; + profile.id = profile.username; + req.user = profile; - // Now check if the user is an admin - const isAdmin = await ldaphelper.isUserInAdGroup(profile.username, domain, adminGroup); + console.log( + `passport.activeDirectory: resolved login ${ + profile._json.userPrincipalName + }, profile=${JSON.stringify(profile)}`, + ); + // First check to see if the user is in the usergroups + const isUser = await ldaphelper.isUserInAdGroup(profile.username, domain, userGroup); - profile.admin = isAdmin; - console.log(`passport.activeDirectory: ${profile.username} admin=${isAdmin}`); + if (!isUser) { + const message = `User it not a member of ${userGroup}`; + return done(message, null); + } - const user = { - username: profile.username, - admin: isAdmin, - email: profile._json.mail, - displayName: profile.displayName, - title: profile._json.title, - }; + // Now check if the user is an admin + const isAdmin = await ldaphelper.isUserInAdGroup(profile.username, domain, adminGroup); - await db.updateUser(user); + profile.admin = isAdmin; + console.log(`passport.activeDirectory: ${profile.username} admin=${isAdmin}`); - return done(null, user); - }, + const user = { + username: profile.username, + admin: isAdmin, + email: profile._json.mail, + displayName: profile.displayName, + title: profile._json.title, + }; + + await db.updateUser(user); + + return done(null, user); + } catch (err) { + console.log(`Error authenticating AD user: ${err.message}`); + return done(err, null); + } + } ), ); @@ -69,4 +77,4 @@ const configure = () => { return passport; }; -module.exports.configure = configure; +module.exports = { configure }; diff --git a/src/service/passport/local.js b/src/service/passport/local.js index 6bcce7e7e..ebe592b2e 100644 --- a/src/service/passport/local.js +++ b/src/service/passport/local.js @@ -1,53 +1,53 @@ -const bcrypt = require('bcryptjs'); -/* eslint-disable max-len */ -const configure = async () => { - const passport = require('passport'); - const Strategy = require('passport-local').Strategy; - const db = require('../../db'); +const bcrypt = require("bcryptjs"); +const LocalStrategy = require("passport-local").Strategy; +const db = require("../../db"); +const configure = async (passport) => { passport.use( - new Strategy((username, password, cb) => { - db.findUser(username) - .then(async (user) => { - if (!user) { - return cb(null, false); - } - - const passwordCorrect = await bcrypt.compare(password, user.password); - - if (!passwordCorrect) { - return cb(null, false); - } - return cb(null, user); - }) - .catch((err) => { - return cb(err); - }); - }), + new LocalStrategy(async (username, password, done) => { + try { + const user = await db.findUser(username); + if (!user) { + return done(null, false, { message: "Incorrect username." }); + } + + const passwordCorrect = await bcrypt.compare(password, user.password); + if (!passwordCorrect) { + return done(null, false, { message: "Incorrect password." }); + } + + return done(null, user); + } catch (err) { + return done(err); + } + }) ); - passport.serializeUser(function (user, cb) { - cb(null, user.username); + passport.serializeUser((user, done) => { + done(null, user.username); }); - passport.deserializeUser(function (username, cb) { - db.findUser(username) - .then((user) => { - cb(null, user); - }) - .catch((err) => { - db(err, null); - }); + passport.deserializeUser(async (username, done) => { + try { + const user = await db.findUser(username); + done(null, user); + } catch (err) { + done(err, null); + } }); - const admin = await db.findUser('admin'); + passport.type = "local"; + return passport; +}; +/** + * Create the default admin user if it doesn't exist + */ +const createDefaultAdmin = async () => { + const admin = await db.findUser("admin"); if (!admin) { - await db.createUser('admin', 'admin', 'admin@place.com', 'none', true, true, true, true); + await db.createUser("admin", "admin", "admin@place.com", "none", true, true, true, true); } - - passport.type = 'local'; - return passport; }; -module.exports.configure = configure; +module.exports = { configure, createDefaultAdmin }; diff --git a/src/service/passport/oidc.js b/src/service/passport/oidc.js index 8e49866f5..e402725e6 100644 --- a/src/service/passport/oidc.js +++ b/src/service/passport/oidc.js @@ -1,20 +1,22 @@ -const configure = async () => { - const passport = require('passport'); - const { Strategy: OIDCStrategy } = require('passport-openidconnect'); +const { Strategy: OIDCStrategy } = require('passport-openidconnect'); + +const configure = async (passport) => { const db = require('../../db'); - const config = require('../../config').getAuthentication(); - const oidcConfig = config.oidcConfig; + // We can refactor this by normalizing auth strategy config and pass it directly into the configure() function, + // ideally when we convert this to TS. + const authMethods = require('../../config').getAuthMethods(); + const oidcConfig = authMethods.find((method) => method.type.toLowerCase() === "openidconnect")?.oidcConfig; passport.use( - new OIDCStrategy(oidcConfig, async function verify(issuer, profile, cb) { + new OIDCStrategy(oidcConfig, async function verify(issuer, profile, done) { try { const user = await db.findUserByOIDC(profile.id); if (!user) { const email = safelyExtractEmail(profile); if (!email) { - return cb(new Error('No email found in OIDC profile')); + return done(new Error('No email found in OIDC profile')); } const username = getUsername(email); @@ -33,25 +35,25 @@ const configure = async () => { newUser.oidcId, ); - return cb(null, newUser); + return done(null, newUser); } - return cb(null, user); + return done(null, user); } catch (err) { - return cb(err); + return done(err); } }), ); - passport.serializeUser((user, cb) => { - cb(null, user.oidcId || user.username); + passport.serializeUser((user, done) => { + done(null, user.oidcId || user.username); }); - passport.deserializeUser(async (id, cb) => { + passport.deserializeUser(async (id, done) => { try { const user = (await db.findUserByOIDC(id)) || (await db.findUser(id)); - cb(null, user); + done(null, user); } catch (err) { - cb(err); + done(err); } }); @@ -59,7 +61,7 @@ const configure = async () => { return passport; }; -module.exports.configure = configure; +module.exports = { configure }; /** * Extracts email from OIDC profile. diff --git a/src/service/routes/auth.js b/src/service/routes/auth.js index 79103e305..33b5fae35 100644 --- a/src/service/routes/auth.js +++ b/src/service/routes/auth.js @@ -28,7 +28,7 @@ router.get('/', (req, res) => { }); }); -router.post('/login', passport.authenticate(passportType), async (req, res) => { +router.post('/login', passport.authenticate('local'), async (req, res) => { try { const currentUser = { ...req.user }; delete currentUser.password; @@ -48,7 +48,7 @@ router.post('/login', passport.authenticate(passportType), async (req, res) => { } }); -router.get('/oidc', passport.authenticate(passportType)); +router.get('/oidc', passport.authenticate('openidconnect')); router.get('/oidc/callback', (req, res, next) => { passport.authenticate(passportType, (err, user, info) => { From ce6023b381eeefd505b3235d6c09c41902682d1e Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 16 Feb 2025 16:35:52 +0900 Subject: [PATCH 15/47] feat(auth): configure passport with all enabled auth methods --- src/config/index.js | 24 +++++++++++--------- src/service/passport/index.js | 42 ++++++++++++++++++----------------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/src/config/index.js b/src/config/index.js index 78184d413..ddc4545f5 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -70,27 +70,29 @@ const getDatabase = () => { throw Error('No database cofigured!'); }; -// Gets the configuared data sink, defaults to filesystem -const getAuthentication = () => { +/** + * Get the list of enabled authentication methods + * @returns {Array} List of enabled authentication methods + */ +const getAuthMethods = () => { if (_userSettings !== null && _userSettings.authentication) { _authentication = _userSettings.authentication; } - for (const ix in _authentication) { - if (!ix) continue; - const auth = _authentication[ix]; - if (auth.enabled) { - return auth; - } + + const enabledAuthMethods = _authentication.filter(auth => auth.enabled); + + if (enabledAuthMethods.length === 0) { + throw new Error("No authentication method enabled."); } - throw Error('No authentication cofigured!'); + return enabledAuthMethods; }; // Log configuration to console const logConfiguration = () => { console.log(`authorisedList = ${JSON.stringify(getAuthorisedList())}`); console.log(`data sink = ${JSON.stringify(getDatabase())}`); - console.log(`authentication = ${JSON.stringify(getAuthentication())}`); + console.log(`authentication = ${JSON.stringify(getAuthMethods())}`); }; const getAPIs = () => { @@ -202,7 +204,7 @@ exports.getProxyUrl = getProxyUrl; exports.getAuthorisedList = getAuthorisedList; exports.getDatabase = getDatabase; exports.logConfiguration = logConfiguration; -exports.getAuthentication = getAuthentication; +exports.getAuthMethods = getAuthMethods; exports.getTempPasswordConfig = getTempPasswordConfig; exports.getCookieSecret = getCookieSecret; exports.getSessionMaxAgeHours = getSessionMaxAgeHours; diff --git a/src/service/passport/index.js b/src/service/passport/index.js index 9a16ef2fc..5b7d593ff 100644 --- a/src/service/passport/index.js +++ b/src/service/passport/index.js @@ -1,31 +1,33 @@ +const passport = require("passport"); const local = require('./local'); const activeDirectory = require('./activeDirectory'); const oidc = require('./oidc'); const config = require('../../config'); -const authenticationConfig = config.getAuthentication(); -let _passport; + +const authStrategies = { + local: local, + activedirectory: activeDirectory, + openidconnect: oidc, +}; const configure = async () => { - const type = authenticationConfig.type.toLowerCase(); + passport.initialize(); - switch (type) { - case 'activedirectory': - _passport = await activeDirectory.configure(); - break; - case 'local': - _passport = await local.configure(); - break; - case 'openidconnect': - _passport = await oidc.configure(); - break; - default: - throw Error(`uknown authentication type ${type}`); + const authMethods = config.getAuthMethods(); + + for (const auth of authMethods) { + const strategy = authStrategies[auth.type.toLowerCase()]; + if (strategy && typeof strategy.configure === "function") { + await strategy.configure(passport); + } } - _passport.type = authenticationConfig.type; - return _passport; + + if (authMethods.some(auth => auth.type.toLowerCase() === "local")) { + await local.createDefaultAdmin(); + } + + return passport; }; module.exports.configure = configure; -module.exports.getPassport = () => { - return _passport; -}; +module.exports.getPassport = () => passport; From 68fa11544851c7eaf7bb5e7eea60fba105d4a49b Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 16 Feb 2025 16:36:51 +0900 Subject: [PATCH 16/47] test(auth): fix tests for multiple auth methods --- test/testConfig.test.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/test/testConfig.test.js b/test/testConfig.test.js index 125ee7b44..e803adb69 100644 --- a/test/testConfig.test.js +++ b/test/testConfig.test.js @@ -11,8 +11,9 @@ describe('default configuration', function () { it('should use default values if no user-settings.json file exists', function () { const config = require('../src/config'); config.logConfiguration(); + const enabledMethods = defaultSettings.authentication.filter(method => method.enabled); - expect(config.getAuthentication()).to.be.eql(defaultSettings.authentication[0]); + expect(config.getAuthMethods()).to.deep.equal(enabledMethods); expect(config.getDatabase()).to.be.eql(defaultSettings.sink[0]); expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword); expect(config.getAuthorisedList()).to.be.eql(defaultSettings.authorisedList); @@ -47,9 +48,10 @@ describe('user configuration', function () { fs.writeFileSync(tempUserFile, JSON.stringify(user)); const config = require('../src/config'); + const enabledMethods = defaultSettings.authentication.filter(method => method.enabled); expect(config.getAuthorisedList()).to.be.eql(user.authorisedList); - expect(config.getAuthentication()).to.be.eql(defaultSettings.authentication[0]); + expect(config.getAuthMethods()).to.deep.equal(enabledMethods); expect(config.getDatabase()).to.be.eql(defaultSettings.sink[0]); expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword); }); @@ -66,9 +68,13 @@ describe('user configuration', function () { fs.writeFileSync(tempUserFile, JSON.stringify(user)); const config = require('../src/config'); + const authMethods = config.getAuthMethods(); + const googleAuth = authMethods.find(method => method.type === 'google'); - expect(config.getAuthentication()).to.be.eql(user.authentication[0]); - expect(config.getAuthentication()).to.not.be.eql(defaultSettings.authentication[0]); + expect(googleAuth).to.not.be.undefined; + expect(googleAuth.enabled).to.be.true; + expect(config.getAuthMethods()).to.deep.include(user.authentication[0]); + expect(config.getAuthMethods()).to.not.be.eql(defaultSettings.authentication); expect(config.getDatabase()).to.be.eql(defaultSettings.sink[0]); expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword); }); @@ -85,10 +91,11 @@ describe('user configuration', function () { fs.writeFileSync(tempUserFile, JSON.stringify(user)); const config = require('../src/config'); + const enabledMethods = defaultSettings.authentication.filter(method => method.enabled); expect(config.getDatabase()).to.be.eql(user.sink[0]); expect(config.getDatabase()).to.not.be.eql(defaultSettings.sink[0]); - expect(config.getAuthentication()).to.be.eql(defaultSettings.authentication[0]); + expect(config.getAuthMethods()).to.deep.equal(enabledMethods); expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword); }); From 627c0eade8f36b9dfc4127fb5a58551d1ac7b3ca Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 21 Feb 2025 14:17:20 +0900 Subject: [PATCH 17/47] fix(auth): refactor how auth strategies are loaded into API route middleware --- src/service/passport/local.js | 5 +++-- src/service/routes/auth.js | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/service/passport/local.js b/src/service/passport/local.js index ebe592b2e..746434c6c 100644 --- a/src/service/passport/local.js +++ b/src/service/passport/local.js @@ -2,6 +2,8 @@ const bcrypt = require("bcryptjs"); const LocalStrategy = require("passport-local").Strategy; const db = require("../../db"); +const type = "local"; + const configure = async (passport) => { passport.use( new LocalStrategy(async (username, password, done) => { @@ -36,7 +38,6 @@ const configure = async (passport) => { } }); - passport.type = "local"; return passport; }; @@ -50,4 +51,4 @@ const createDefaultAdmin = async () => { } }; -module.exports = { configure, createDefaultAdmin }; +module.exports = { configure, createDefaultAdmin, type }; diff --git a/src/service/routes/auth.js b/src/service/routes/auth.js index e146e3d87..b6bce5fa6 100644 --- a/src/service/routes/auth.js +++ b/src/service/routes/auth.js @@ -1,8 +1,8 @@ const express = require('express'); const router = new express.Router(); const passport = require('../passport').getPassport(); +const authStrategies = require('../passport').authStrategies; const db = require('../../db'); -const passportType = passport.type; const { GIT_PROXY_UI_HOST: uiHost = 'http://localhost', GIT_PROXY_UI_PORT: uiPort = 3000 } = process.env; router.get('/', (req, res) => { @@ -22,7 +22,7 @@ router.get('/', (req, res) => { }); }); -router.post('/login', passport.authenticate('local'), async (req, res) => { +router.post('/login', passport.authenticate(authStrategies['local'].type), async (req, res) => { try { const currentUser = { ...req.user }; delete currentUser.password; @@ -42,10 +42,10 @@ router.post('/login', passport.authenticate('local'), async (req, res) => { } }); -router.get('/oidc', passport.authenticate('openidconnect')); +router.get('/oidc', passport.authenticate(authStrategies['openidconnect'].type)); router.get('/oidc/callback', (req, res, next) => { - passport.authenticate(passportType, (err, user, info) => { + passport.authenticate(authStrategies['openidconnect'].type, (err, user, info) => { if (err) { console.error('Authentication error:', err); return res.status(401).end(); From 534beeb1514c82da8ce8d2b635ece7fb64d8d390 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 1 Mar 2025 15:32:13 +0900 Subject: [PATCH 18/47] feat(auth): add JWT strategy and fix --- src/service/passport/jwt.js | 26 ++++++++++++++++++++++++++ src/service/passport/oidc.js | 5 ++--- 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 src/service/passport/jwt.js diff --git a/src/service/passport/jwt.js b/src/service/passport/jwt.js new file mode 100644 index 000000000..f2b2dadb1 --- /dev/null +++ b/src/service/passport/jwt.js @@ -0,0 +1,26 @@ +const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); + +const type = "jwt"; + +const configure = (passport) => { + const JWT_SECRET = process.env.JWT_SECRET; + + if (!JWT_SECRET) { + console.log('JWT secret not provided. Skipping JWT strategy registration.'); + return; // Skip JWT registration if not configured + } + + const opts = { + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: JWT_SECRET, + }; + + passport.use('jwt', new JwtStrategy(opts, (jwtPayload, done) => { + if (!jwtPayload) return done(null, false); + return done(null, jwtPayload); + })); + + console.log('JWT strategy registered successfully.'); +}; + +module.exports = { configure, type }; diff --git a/src/service/passport/oidc.js b/src/service/passport/oidc.js index baf65a5a7..6681a6a8b 100644 --- a/src/service/passport/oidc.js +++ b/src/service/passport/oidc.js @@ -1,11 +1,10 @@ const openIdClient = require('openid-client'); const { Strategy } = require('openid-client/passport'); -const passport = require('passport'); const db = require('../../db'); let type; -const configure = async () => { +const configure = async (passport) => { const authMethods = require('../../config').getAuthMethods(); const oidcConfig = authMethods.find((method) => method.type.toLowerCase() === "openidconnect")?.oidcConfig; const { issuer, clientID, clientSecret, callbackURL, scope } = oidcConfig; @@ -26,7 +25,7 @@ const configure = async () => { const userInfo = await openIdClient.fetchUserInfo(config, tokenSet.access_token, expectedSub); handleUserAuthentication(userInfo, done); }); - + // currentUrl must be overridden to match the callback URL strategy.currentUrl = (request) => { const callbackUrl = new URL(callbackURL); From 8b1a17363a81ccdc40d925e86e3630dcc616c8f3 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 1 Mar 2025 15:33:23 +0900 Subject: [PATCH 19/47] feat(auth): add dynamicAuthHandler middleware --- package-lock.json | 275 +++++++++++++++++++-- package.json | 4 +- src/service/passport/dynamicAuthHandler.js | 36 +++ src/service/passport/index.js | 3 + src/service/routes/repo.js | 3 +- 5 files changed, 303 insertions(+), 18 deletions(-) create mode 100644 src/service/passport/dynamicAuthHandler.js diff --git a/package-lock.json b/package-lock.json index 65bf8342e..74cc6994e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,10 +38,12 @@ "moment": "^2.29.4", "mongodb": "^5.0.0", "nodemailer": "^6.6.1", - "openid-client": "^6.2.0", + "open": "^10.1.0", + "openid-client": "^6.3.1", "parse-diff": "^0.11.1", "passport": "^0.7.0", "passport-activedirectory": "^1.0.4", + "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "perfect-scrollbar": "^1.5.5", "prop-types": "15.8.1", @@ -4393,6 +4395,27 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -5358,6 +5381,34 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/default-require-extensions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", @@ -5398,6 +5449,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -5598,6 +5661,15 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7879,6 +7951,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7943,6 +8030,24 @@ "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==" }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-installed-globally": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", @@ -8224,6 +8329,21 @@ "node": ">=0.10.0" } }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -8501,9 +8621,9 @@ } }, "node_modules/jose": { - "version": "5.9.6", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", - "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -8650,6 +8770,40 @@ "node": "*" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", @@ -8800,6 +8954,27 @@ "dev": true, "license": "MIT" }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9026,11 +9201,40 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" }, "node_modules/lodash.kebabcase": { "version": "4.1.1", @@ -9055,8 +9259,7 @@ "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" }, "node_modules/lodash.snakecase": { "version": "4.1.1", @@ -9977,9 +10180,9 @@ } }, "node_modules/oauth4webapi": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.2.0.tgz", - "integrity": "sha512-2sYwQXuuzGKOHpnM7QL9BssDrly5gKCgJKTyrhmFIHzJRj0fFsr6GVJEdesmrX6NpMg2u63V4hJwRsZE6PUSSA==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.3.0.tgz", + "integrity": "sha512-ZlozhPlFfobzh3hB72gnBFLjXpugl/dljz1fJSRdqaV2r3D5dmi5lg2QWI0LmUYuazmE+b5exsloEv6toUtw9g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -10131,14 +10334,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", + "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openid-client": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.2.0.tgz", - "integrity": "sha512-pvLVkLcRWNU7YuKKTto376rgL//+rn3ca0XRqsrQVN30lVlpXBPHhSLcGoM/hPbux5p+Ha4tdoz96eEYpyguOQ==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.3.1.tgz", + "integrity": "sha512-l+uRCCM+KaGKQmCWjrjlFHXa1husuc72OPCCyGB7VjHeEMVldfwsn4Pfb/5Xk51FRqbRakMbwfIUPiAMQDHaSw==", "license": "MIT", "dependencies": { - "jose": "^5.9.6", - "oauth4webapi": "^3.2.0" + "jose": "^5.10.0", + "oauth4webapi": "^3.3.0" }, "funding": { "url": "https://github.com/sponsors/panva" @@ -10342,6 +10563,16 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, "node_modules/passport-local": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", @@ -11163,6 +11394,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-applescript": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", + "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/package.json b/package.json index cb4ffa98d..40956086b 100644 --- a/package.json +++ b/package.json @@ -59,10 +59,12 @@ "moment": "^2.29.4", "mongodb": "^5.0.0", "nodemailer": "^6.6.1", - "openid-client": "^6.2.0", + "open": "^10.1.0", + "openid-client": "^6.3.1", "parse-diff": "^0.11.1", "passport": "^0.7.0", "passport-activedirectory": "^1.0.4", + "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "perfect-scrollbar": "^1.5.5", "prop-types": "15.8.1", diff --git a/src/service/passport/dynamicAuthHandler.js b/src/service/passport/dynamicAuthHandler.js new file mode 100644 index 000000000..98a557887 --- /dev/null +++ b/src/service/passport/dynamicAuthHandler.js @@ -0,0 +1,36 @@ +const passport = require('./index').getPassport(); + +/** + * Dynamic authentication middleware that supports JWT and session auth. + * If JWT strategy is set up, it will be prioritized. Otherwise, it will fallback to session auth. + * If either strategy is successful, it calls the next middleware in the chain. + * @param {*} passport the passport instance + * @returns a middleware function that handles authentication dynamically + */ +const dynamicAuthHandler = () => { + return (req, res, next) => { + console.log(`Dynamic Auth triggered - Requested URL: ${req.originalUrl}`); + const hasJwtStrategy = !!passport._strategy('jwt'); + console.log('hasJwtStrategy: ' + hasJwtStrategy); + if (hasJwtStrategy) { + // Try JWT authentication first + passport.authenticate('jwt', { session: false }, (err, user, info) => { + console.log(`JWT Auth triggered - User: ${user} - Error: ${err}`); + if (err) return next(err); + if (user) { + req.user = user; // JWT authenticated user + return next(); + } + // Fallback to session if available + if (req.isAuthenticated && req.isAuthenticated()) return next(); + return res.status(401).json({ message: 'Unauthorized: Valid token or session required.' }); + })(req, res, next); + } else { + // Default to session auth + if (req.isAuthenticated && req.isAuthenticated()) return next(); + return res.status(401).json({ message: 'Unauthorized: No active session.' }); + } + }; +} + +module.exports = dynamicAuthHandler; diff --git a/src/service/passport/index.js b/src/service/passport/index.js index ce62719d2..7126ae10f 100644 --- a/src/service/passport/index.js +++ b/src/service/passport/index.js @@ -2,6 +2,7 @@ const passport = require("passport"); const local = require('./local'); const activeDirectory = require('./activeDirectory'); const oidc = require('./oidc'); +const jwt = require('./jwt'); const config = require('../../config'); // Allows obtaining strategy config function and type @@ -10,12 +11,14 @@ const authStrategies = { local: local, activedirectory: activeDirectory, openidconnect: oidc, + jwt: jwt, }; const configure = async () => { passport.initialize(); const authMethods = config.getAuthMethods(); + console.log(`authMethods: ${JSON.stringify(authMethods)}`); for (const auth of authMethods) { const strategy = authStrategies[auth.type.toLowerCase()]; diff --git a/src/service/routes/repo.js b/src/service/routes/repo.js index 1f6698e3b..36a7b88f2 100644 --- a/src/service/routes/repo.js +++ b/src/service/routes/repo.js @@ -2,8 +2,9 @@ const express = require('express'); const router = new express.Router(); const db = require('../../db'); const { getProxyURL } = require('../urls'); +const dynamicAuthHandler = require('../passport/dynamicAuthHandler'); -router.get('/', async (req, res) => { +router.get('/', dynamicAuthHandler(), async (req, res) => { const proxyURL = getProxyURL(req); const query = { type: 'push', From 6265bbd60de69bd3ab527a334b42ea351a8147cc Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 8 Mar 2025 13:28:56 +0900 Subject: [PATCH 20/47] feat(auth): update JWT auth middleware --- src/service/passport/dynamicAuthHandler.js | 36 ------- src/service/passport/jwtAuthHandler.js | 106 +++++++++++++++++++++ 2 files changed, 106 insertions(+), 36 deletions(-) delete mode 100644 src/service/passport/dynamicAuthHandler.js create mode 100644 src/service/passport/jwtAuthHandler.js diff --git a/src/service/passport/dynamicAuthHandler.js b/src/service/passport/dynamicAuthHandler.js deleted file mode 100644 index 98a557887..000000000 --- a/src/service/passport/dynamicAuthHandler.js +++ /dev/null @@ -1,36 +0,0 @@ -const passport = require('./index').getPassport(); - -/** - * Dynamic authentication middleware that supports JWT and session auth. - * If JWT strategy is set up, it will be prioritized. Otherwise, it will fallback to session auth. - * If either strategy is successful, it calls the next middleware in the chain. - * @param {*} passport the passport instance - * @returns a middleware function that handles authentication dynamically - */ -const dynamicAuthHandler = () => { - return (req, res, next) => { - console.log(`Dynamic Auth triggered - Requested URL: ${req.originalUrl}`); - const hasJwtStrategy = !!passport._strategy('jwt'); - console.log('hasJwtStrategy: ' + hasJwtStrategy); - if (hasJwtStrategy) { - // Try JWT authentication first - passport.authenticate('jwt', { session: false }, (err, user, info) => { - console.log(`JWT Auth triggered - User: ${user} - Error: ${err}`); - if (err) return next(err); - if (user) { - req.user = user; // JWT authenticated user - return next(); - } - // Fallback to session if available - if (req.isAuthenticated && req.isAuthenticated()) return next(); - return res.status(401).json({ message: 'Unauthorized: Valid token or session required.' }); - })(req, res, next); - } else { - // Default to session auth - if (req.isAuthenticated && req.isAuthenticated()) return next(); - return res.status(401).json({ message: 'Unauthorized: No active session.' }); - } - }; -} - -module.exports = dynamicAuthHandler; diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js new file mode 100644 index 000000000..a112d8cb9 --- /dev/null +++ b/src/service/passport/jwtAuthHandler.js @@ -0,0 +1,106 @@ +const axios = require("axios"); +const jwt = require("jsonwebtoken"); +const jwkToPem = require("jwk-to-pem"); + +/** + * Obtain the JSON Web Key Set (JWKS) from the OIDC authority. + * @param {string} authorityUrl the OIDC authority URL. e.g. https://login.microsoftonline.com/{tenantId} + * @returns {Promise} the JWKS keys + */ +async function getJwks(authorityUrl) { + try { + const { data } = await axios.get(`${authorityUrl}/.well-known/openid-configuration`); + const jwksUri = data.jwks_uri; + + const { data: jwks } = await axios.get(jwksUri); + return jwks.keys; + } catch (error) { + console.error("Error fetching JWKS:", error); + throw new Error("Failed to fetch JWKS"); + } +} + +/** + * Validate a JWT token using the OIDC configuration. + * @param {*} token the JWT token + * @param {*} authorityUrl the OIDC authority URL + * @param {*} clientID the OIDC client ID + * @param {*} expectedAudience the expected audience for the token + * @returns {Promise} the verified payload or an error + */ +async function validateJwt(token, authorityUrl, clientID, expectedAudience) { + try { + const jwks = await getJwks(authorityUrl); + + const decodedHeader = await jwt.decode(token, { complete: true }); + if (!decodedHeader || !decodedHeader.header || !decodedHeader.header.kid) { + throw new Error("Invalid JWT: Missing key ID (kid)"); + } + + const { kid } = decodedHeader.header; + const jwk = jwks.find((key) => key.kid === kid); + if (!jwk) { + throw new Error("No matching key found in JWKS"); + } + + const pubKey = jwkToPem(jwk); + + const verifiedPayload = jwt.verify(token, pubKey, { + algorithms: ["RS256"], + issuer: authorityUrl, + audience: expectedAudience, + }); + + if (verifiedPayload.azp !== clientID) { + throw new Error("JWT client ID does not match"); + } + + return { verifiedPayload }; + } catch (error) { + const errorMessage = `JWT validation failed: ${error.message}`; + console.error(errorMessage); + return { error: errorMessage }; + } +} + +const jwtAuthHandler = () => { + return async (req, res, next) => { + if (process.env.OIDC_AUTH_ENABLED !== "true") { + return next(); + } + + if (req.isAuthenticated()) { + console.log('request is already authenticated'); + return next(); + } + + const token = req.header("Authorization"); + if (!token) { + return res.status(401).send("No token provided"); + } + + const clientID = process.env.OIDC_CLIENT_ID; + const authorityUrl = process.env.OIDC_AUTHORITY; + const expectedAudience = process.env.OIDC_AUDIENCE || clientID; + + if (!authorityUrl) { + return res.status(500).send("OIDC authority URL is not configured"); + } + + if (!clientID) { + return res.status(500).send("OIDC client ID is not configured"); + } + + const tokenParts = token.split(" "); + const { verifiedPayload, error } = await validateJwt(tokenParts[1], authorityUrl, expectedAudience, clientID); + if (error) { + return res.status(401).send(error); + } + + req.user = verifiedPayload; + console.log('request authenticated through JWT') + return next(); + } +} + +module.exports = jwtAuthHandler; From 5202f7eecc86c441a8ca81952a2b672c022be88c Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 8 Mar 2025 13:42:49 +0900 Subject: [PATCH 21/47] feat(auth): add jwk-to-pem library for jwt validation --- package-lock.json | 60 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 61 insertions(+) diff --git a/package-lock.json b/package-lock.json index 74cc6994e..005f0d811 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "history": "5.3.0", "isomorphic-git": "^1.27.1", "jsonschema": "^1.4.1", + "jwk-to-pem": "^2.0.7", "load-plugin": "^6.0.0", "lodash": "^4.17.21", "lusca": "^1.7.0", @@ -4315,6 +4316,12 @@ "node": ">=8" } }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", @@ -5682,6 +5689,21 @@ "dev": true, "license": "ISC" }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -7439,6 +7461,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasha": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", @@ -7511,6 +7543,17 @@ "@babel/runtime": "^7.7.6" } }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/hogan.js": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/hogan.js/-/hogan.js-3.0.2.tgz", @@ -8965,6 +9008,17 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwk-to-pem": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/jwk-to-pem/-/jwk-to-pem-2.0.7.tgz", + "integrity": "sha512-cSVphrmWr6reVchuKQZdfSs4U9c5Y4hwZggPoz6cbVnTpAVgGRpEuQng86IyqLeGZlhTh+c4MAreB6KbdQDKHQ==", + "license": "Apache-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "elliptic": "^6.6.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/jws": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", @@ -9561,6 +9615,12 @@ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", diff --git a/package.json b/package.json index 40956086b..a6b25aa16 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "history": "5.3.0", "isomorphic-git": "^1.27.1", "jsonschema": "^1.4.1", + "jwk-to-pem": "^2.0.7", "load-plugin": "^6.0.0", "lodash": "^4.17.21", "lusca": "^1.7.0", From e8d08780c3366a46e46f2eb21b73ecfec3261579 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 8 Mar 2025 13:57:18 +0900 Subject: [PATCH 22/47] feat(auth): convert envs to proxy.config.json entries (jwt) --- src/service/passport/jwtAuthHandler.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js index a112d8cb9..8208cc464 100644 --- a/src/service/passport/jwtAuthHandler.js +++ b/src/service/passport/jwtAuthHandler.js @@ -65,7 +65,11 @@ async function validateJwt(token, authorityUrl, clientID, expectedAudience) { const jwtAuthHandler = () => { return async (req, res, next) => { - if (process.env.OIDC_AUTH_ENABLED !== "true") { + const authMethods = require('../../config').getAuthMethods(); + const jwtAuthMethod = authMethods.find((method) => method.type.toLowerCase() === "jwt"); + const { clientID, authorityURL, expectedAudience } = jwtAuthMethod?.jwtConfig; + + if (jwtAuthMethod.enabled) { return next(); } @@ -79,11 +83,9 @@ const jwtAuthHandler = () => { return res.status(401).send("No token provided"); } - const clientID = process.env.OIDC_CLIENT_ID; - const authorityUrl = process.env.OIDC_AUTHORITY; - const expectedAudience = process.env.OIDC_AUDIENCE || clientID; + let audience = expectedAudience || clientID; - if (!authorityUrl) { + if (!authorityURL) { return res.status(500).send("OIDC authority URL is not configured"); } @@ -92,13 +94,12 @@ const jwtAuthHandler = () => { } const tokenParts = token.split(" "); - const { verifiedPayload, error } = await validateJwt(tokenParts[1], authorityUrl, expectedAudience, clientID); + const { verifiedPayload, error } = await validateJwt(tokenParts[1], authorityURL, audience, clientID); if (error) { return res.status(401).send(error); } req.user = verifiedPayload; - console.log('request authenticated through JWT') return next(); } } From c5b45b2b2dfbfb631cd1eb8cffb44a9ca3128f64 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 8 Mar 2025 14:07:19 +0900 Subject: [PATCH 23/47] feat(auth): add missing proxy.config.json auth methods --- proxy.config.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/proxy.config.json b/proxy.config.json index 14d016e4d..4e6863450 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -49,6 +49,29 @@ "baseDN": "", "searchBase": "" } + }, + { + "type": "", + "enabled": false, + "oidcConfig": { + "issuer": "", + "clientID": "", + "clientSecret": "", + "authorizationURL": "", + "tokenURL": "", + "userInfoURL": "", + "callbackURL": "", + "scope": "" + } + }, + { + "type": "jwt", + "enabled": false, + "jwtConfig": { + "clientID": "", + "authorityURL": "", + "expecetedAudience": "" + } } ], "api": { From b0c500c00673d02a0ef9921621489d34e674f364 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 8 Mar 2025 14:36:10 +0900 Subject: [PATCH 24/47] fix(auth): fix undefined errors --- proxy.config.json | 2 +- src/service/passport/jwtAuthHandler.js | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/proxy.config.json b/proxy.config.json index 4e6863450..dadcf7346 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -51,7 +51,7 @@ } }, { - "type": "", + "type": "oidc", "enabled": false, "oidcConfig": { "issuer": "", diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js index 8208cc464..af97e8640 100644 --- a/src/service/passport/jwtAuthHandler.js +++ b/src/service/passport/jwtAuthHandler.js @@ -67,9 +67,7 @@ const jwtAuthHandler = () => { return async (req, res, next) => { const authMethods = require('../../config').getAuthMethods(); const jwtAuthMethod = authMethods.find((method) => method.type.toLowerCase() === "jwt"); - const { clientID, authorityURL, expectedAudience } = jwtAuthMethod?.jwtConfig; - - if (jwtAuthMethod.enabled) { + if (!jwtAuthMethod) { return next(); } @@ -83,6 +81,7 @@ const jwtAuthHandler = () => { return res.status(401).send("No token provided"); } + const { clientID, authorityURL, expectedAudience } = jwtAuthMethod?.jwtConfig; let audience = expectedAudience || clientID; if (!authorityURL) { From 1446fcddbc979f0815580cee1db4fa694aceef52 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 8 Mar 2025 14:37:42 +0900 Subject: [PATCH 25/47] fix(auth): fix mislabeled auth method --- proxy.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proxy.config.json b/proxy.config.json index dadcf7346..07d3b1af6 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -51,7 +51,7 @@ } }, { - "type": "oidc", + "type": "openidconnect", "enabled": false, "oidcConfig": { "issuer": "", From 85b3fede87bade91a7184406b5f4c39c24ddc821 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 8 Mar 2025 14:46:52 +0900 Subject: [PATCH 26/47] feat(auth): set jwtAuthHandler middleware for /push, /repo and /user routes --- src/service/routes/index.js | 7 ++++--- src/service/routes/repo.js | 3 +-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/service/routes/index.js b/src/service/routes/index.js index a7529a4ac..45b276c17 100644 --- a/src/service/routes/index.js +++ b/src/service/routes/index.js @@ -6,14 +6,15 @@ const repo = require('./repo'); const users = require('./users'); const healthcheck = require('./healthcheck'); const config = require('./config'); +const jwtAuthHandler = require('../passport/jwtAuthHandler'); const router = new express.Router(); router.use('/api', home); router.use('/api/auth', auth); router.use('/api/v1/healthcheck', healthcheck); -router.use('/api/v1/push', push); -router.use('/api/v1/repo', repo); -router.use('/api/v1/user', users); +router.use('/api/v1/push', jwtAuthHandler(), push); +router.use('/api/v1/repo', jwtAuthHandler(), repo); +router.use('/api/v1/user', jwtAuthHandler(), users); router.use('/api/v1/config', config); module.exports = router; diff --git a/src/service/routes/repo.js b/src/service/routes/repo.js index 36a7b88f2..1f6698e3b 100644 --- a/src/service/routes/repo.js +++ b/src/service/routes/repo.js @@ -2,9 +2,8 @@ const express = require('express'); const router = new express.Router(); const db = require('../../db'); const { getProxyURL } = require('../urls'); -const dynamicAuthHandler = require('../passport/dynamicAuthHandler'); -router.get('/', dynamicAuthHandler(), async (req, res) => { +router.get('/', async (req, res) => { const proxyURL = getProxyURL(req); const query = { type: 'push', From 3410c2940f1681cf0b7de2ac1b229b0a41a2482b Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 8 Mar 2025 15:12:10 +0900 Subject: [PATCH 27/47] chore(auth): fix linter errors --- src/config/index.js | 2 +- src/service/passport/jwtAuthHandler.js | 4 ++-- src/service/passport/oidc.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/index.js b/src/config/index.js index ddc4545f5..f9754859a 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -72,7 +72,7 @@ const getDatabase = () => { /** * Get the list of enabled authentication methods - * @returns {Array} List of enabled authentication methods + * @return {Array} List of enabled authentication methods */ const getAuthMethods = () => { if (_userSettings !== null && _userSettings.authentication) { diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js index af97e8640..336c16e90 100644 --- a/src/service/passport/jwtAuthHandler.js +++ b/src/service/passport/jwtAuthHandler.js @@ -5,7 +5,7 @@ const jwkToPem = require("jwk-to-pem"); /** * Obtain the JSON Web Key Set (JWKS) from the OIDC authority. * @param {string} authorityUrl the OIDC authority URL. e.g. https://login.microsoftonline.com/{tenantId} - * @returns {Promise} the JWKS keys + * @return {Promise} the JWKS keys */ async function getJwks(authorityUrl) { try { @@ -26,7 +26,7 @@ async function getJwks(authorityUrl) { * @param {*} authorityUrl the OIDC authority URL * @param {*} clientID the OIDC client ID * @param {*} expectedAudience the expected audience for the token - * @returns {Promise} the verified payload or an error + * @return {Promise} the verified payload or an error */ async function validateJwt(token, authorityUrl, clientID, expectedAudience) { try { diff --git a/src/service/passport/oidc.js b/src/service/passport/oidc.js index 6681a6a8b..1adbfb215 100644 --- a/src/service/passport/oidc.js +++ b/src/service/passport/oidc.js @@ -63,7 +63,7 @@ const configure = async (passport) => { * Handles user authentication with OIDC. * @param userInfo the OIDC user info object * @param done the callback function - * @returns a promise with the authenticated user or an error + * @return a promise with the authenticated user or an error */ const handleUserAuthentication = async (userInfo, done) => { try { From 2d680adcee6e4754692f17e4397f20d9e6053ca1 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 8 Mar 2025 15:28:25 +0900 Subject: [PATCH 28/47] chore(auth): fix linting errors --- package-lock.json | 134 ------------------------- package.json | 1 - src/service/passport/jwtAuthHandler.js | 4 +- src/service/passport/oidc.js | 10 +- src/ui/views/Login/Login.jsx | 3 +- 5 files changed, 8 insertions(+), 144 deletions(-) diff --git a/package-lock.json b/package-lock.json index 005f0d811..a5563293b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,6 @@ "moment": "^2.29.4", "mongodb": "^5.0.0", "nodemailer": "^6.6.1", - "open": "^10.1.0", "openid-client": "^6.3.1", "parse-diff": "^0.11.1", "passport": "^0.7.0", @@ -4408,21 +4407,6 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -5388,34 +5372,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/default-require-extensions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", @@ -5456,18 +5412,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -7994,21 +7938,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -8073,24 +8002,6 @@ "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==" }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-installed-globally": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", @@ -8372,21 +8283,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -10394,24 +10290,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", - "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/openid-client": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.3.1.tgz", @@ -11454,18 +11332,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-applescript": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", - "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/package.json b/package.json index a6b25aa16..939a67b9f 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "moment": "^2.29.4", "mongodb": "^5.0.0", "nodemailer": "^6.6.1", - "open": "^10.1.0", "openid-client": "^6.3.1", "parse-diff": "^0.11.1", "passport": "^0.7.0", diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js index 336c16e90..58db67964 100644 --- a/src/service/passport/jwtAuthHandler.js +++ b/src/service/passport/jwtAuthHandler.js @@ -81,8 +81,8 @@ const jwtAuthHandler = () => { return res.status(401).send("No token provided"); } - const { clientID, authorityURL, expectedAudience } = jwtAuthMethod?.jwtConfig; - let audience = expectedAudience || clientID; + const { clientID, authorityURL, expectedAudience } = jwtAuthMethod.jwtConfig; + const audience = expectedAudience || clientID; if (!authorityURL) { return res.status(500).send("OIDC authority URL is not configured"); diff --git a/src/service/passport/oidc.js b/src/service/passport/oidc.js index 1adbfb215..057f352eb 100644 --- a/src/service/passport/oidc.js +++ b/src/service/passport/oidc.js @@ -27,7 +27,7 @@ const configure = async (passport) => { }); // currentUrl must be overridden to match the callback URL - strategy.currentUrl = (request) => { + strategy.currentUrl = function(request) { const callbackUrl = new URL(callbackURL); const currentUrl = Strategy.prototype.currentUrl.call(this, request); currentUrl.host = callbackUrl.host; @@ -61,13 +61,13 @@ const configure = async (passport) => { /** * Handles user authentication with OIDC. - * @param userInfo the OIDC user info object - * @param done the callback function - * @return a promise with the authenticated user or an error + * @param {*} userInfo the OIDC user info object + * @param {*} done the callback function + * @return {*} a promise with the authenticated user or an error */ const handleUserAuthentication = async (userInfo, done) => { try { - let user = await db.findUserByOIDC(userInfo.sub); + const user = await db.findUserByOIDC(userInfo.sub); if (!user) { const email = safelyExtractEmail(userInfo); diff --git a/src/ui/views/Login/Login.jsx b/src/ui/views/Login/Login.jsx index 0be3781a6..ec8b3debd 100644 --- a/src/ui/views/Login/Login.jsx +++ b/src/ui/views/Login/Login.jsx @@ -24,8 +24,7 @@ export default function UserProfile() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [message, setMessage] = useState(''); - const [success, setSuccess] = useState(false); - const [gitAccountError, setGitAccountError] = useState(false); + const [, setGitAccountError] = useState(false); const [isLoading, setIsLoading] = useState(false); const navigate = useNavigate(); From e259081507772e842c262989efcc3d0566c11056 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Wed, 2 Apr 2025 23:10:24 +0900 Subject: [PATCH 29/47] fix: add CI debug line and fix config typo --- proxy.config.json | 2 +- test/testLogin.test.js | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/proxy.config.json b/proxy.config.json index 07d3b1af6..7fa4ef059 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -70,7 +70,7 @@ "jwtConfig": { "clientID": "", "authorityURL": "", - "expecetedAudience": "" + "expectedAudience": "" } } ], diff --git a/test/testLogin.test.js b/test/testLogin.test.js index 833184e0b..79e4fb1a7 100644 --- a/test/testLogin.test.js +++ b/test/testLogin.test.js @@ -31,6 +31,13 @@ describe('auth', async () => { password: 'admin', }); + // Debug CI + console.log("DEBUG testLogin.test.js: "); + console.log(`res.body: ${JSON.stringify(res.body)}`); + console.log(`res.headers: ${JSON.stringify(res.headers)}`); + console.log(`res.status: ${res.status}`); + console.log(`res.text: ${res.text}`); + expect(res).to.have.cookie('connect.sid'); res.should.have.status(200); From 04c3878aba77120b60f3f7d6cf7c0f0916914b6d Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 4 Apr 2025 11:45:43 +0900 Subject: [PATCH 30/47] fix: fix createDefaultAdmin bug and add debug lines for CI --- src/service/passport/local.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/service/passport/local.js b/src/service/passport/local.js index 746434c6c..c292de57e 100644 --- a/src/service/passport/local.js +++ b/src/service/passport/local.js @@ -13,6 +13,8 @@ const configure = async (passport) => { return done(null, false, { message: "Incorrect username." }); } + console.log(`Before bcrypt compare: user.password: ${user.password}`); + console.log(`Before bcrypt compare: password: ${password}`); const passwordCorrect = await bcrypt.compare(password, user.password); if (!passwordCorrect) { return done(null, false, { message: "Incorrect password." }); @@ -47,7 +49,8 @@ const configure = async (passport) => { const createDefaultAdmin = async () => { const admin = await db.findUser("admin"); if (!admin) { - await db.createUser("admin", "admin", "admin@place.com", "none", true, true, true, true); + console.log("No admin user found. Creating default admin user..."); + await db.createUser("admin", "admin", "admin@place.com", "none", true); } }; From 3605d73c89cb0ceedd36b4db8dcfc95819a6cd24 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 4 Apr 2025 11:58:39 +0900 Subject: [PATCH 31/47] fix: remove debug lines --- src/service/passport/jwtAuthHandler.js | 1 - src/service/passport/local.js | 3 --- src/service/passport/oidc.js | 2 +- src/ui/auth/AuthProvider.tsx | 3 --- src/ui/components/PrivateRoute/PrivateRoute.tsx | 6 +----- test/testLogin.test.js | 7 ------- 6 files changed, 2 insertions(+), 20 deletions(-) diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js index 58db67964..faa039f50 100644 --- a/src/service/passport/jwtAuthHandler.js +++ b/src/service/passport/jwtAuthHandler.js @@ -72,7 +72,6 @@ const jwtAuthHandler = () => { } if (req.isAuthenticated()) { - console.log('request is already authenticated'); return next(); } diff --git a/src/service/passport/local.js b/src/service/passport/local.js index c292de57e..579d47234 100644 --- a/src/service/passport/local.js +++ b/src/service/passport/local.js @@ -13,8 +13,6 @@ const configure = async (passport) => { return done(null, false, { message: "Incorrect username." }); } - console.log(`Before bcrypt compare: user.password: ${user.password}`); - console.log(`Before bcrypt compare: password: ${password}`); const passwordCorrect = await bcrypt.compare(password, user.password); if (!passwordCorrect) { return done(null, false, { message: "Incorrect password." }); @@ -49,7 +47,6 @@ const configure = async (passport) => { const createDefaultAdmin = async () => { const admin = await db.findUser("admin"); if (!admin) { - console.log("No admin user found. Creating default admin user..."); await db.createUser("admin", "admin", "admin@place.com", "none", true); } }; diff --git a/src/service/passport/oidc.js b/src/service/passport/oidc.js index 284827e5a..6c9cd00a7 100644 --- a/src/service/passport/oidc.js +++ b/src/service/passport/oidc.js @@ -50,7 +50,7 @@ const configure = async (passport) => { done(err); } }) - console.log(`setting type to ${server.host}`) + type = server.host; return passport; diff --git a/src/ui/auth/AuthProvider.tsx b/src/ui/auth/AuthProvider.tsx index 1da89df51..f7ab4b2fc 100644 --- a/src/ui/auth/AuthProvider.tsx +++ b/src/ui/auth/AuthProvider.tsx @@ -16,13 +16,10 @@ export const AuthProvider = ({ children }) => { const [isLoading, setIsLoading] = useState(true); const refreshUser = async () => { - console.log('Refreshing user'); try { const data = await getUserInfo(); setUser(data); - console.log('User refreshed:', data); } catch (error) { - console.error('Error refreshing user:', error); setUser(null); } finally { setIsLoading(false); diff --git a/src/ui/components/PrivateRoute/PrivateRoute.tsx b/src/ui/components/PrivateRoute/PrivateRoute.tsx index 4e7a2f4bf..d010a7d86 100644 --- a/src/ui/components/PrivateRoute/PrivateRoute.tsx +++ b/src/ui/components/PrivateRoute/PrivateRoute.tsx @@ -4,20 +4,16 @@ import { useAuth } from '../../auth/AuthProvider'; const PrivateRoute = ({ component: Component, adminOnly = false }) => { const { user, isLoading } = useAuth(); - console.debug('PrivateRoute', { user, isLoading, adminOnly }); - + if (isLoading) { - console.debug('Auth is loading, waiting'); return
Loading...
; // TODO: Add loading spinner } if (!user) { - console.debug('User not logged in, redirecting to login page'); return ; } if (adminOnly && !user.admin) { - console.debug('User is not an admin, redirecting to not authorized page'); return ; } diff --git a/test/testLogin.test.js b/test/testLogin.test.js index 79e4fb1a7..833184e0b 100644 --- a/test/testLogin.test.js +++ b/test/testLogin.test.js @@ -31,13 +31,6 @@ describe('auth', async () => { password: 'admin', }); - // Debug CI - console.log("DEBUG testLogin.test.js: "); - console.log(`res.body: ${JSON.stringify(res.body)}`); - console.log(`res.headers: ${JSON.stringify(res.headers)}`); - console.log(`res.status: ${res.status}`); - console.log(`res.text: ${res.text}`); - expect(res).to.have.cookie('connect.sid'); res.should.have.status(200); From 1e5d3610ff29c9d71083328c17264c1fec9967ab Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 4 Apr 2025 12:10:23 +0900 Subject: [PATCH 32/47] chore: allow OFL-1.1 license Free license according to FSF (https://www.gnu.org/licenses/license-list.html#SILOFL) --- .github/workflows/dependency-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index fdbc7ecb1..dd4c21bc5 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -21,6 +21,6 @@ jobs: with: comment-summary-in-pr: always fail-on-severity: high - allow-licenses: MIT, MIT-0, Apache-2.0, BSD-3-Clause, BSD-3-Clause-Clear, ISC, BSD-2-Clause, Unlicense, CC0-1.0, 0BSD, X11, MPL-2.0, MPL-1.0, MPL-1.1, MPL-2.0, Zlib + allow-licenses: MIT, MIT-0, Apache-2.0, BSD-3-Clause, BSD-3-Clause-Clear, ISC, BSD-2-Clause, Unlicense, CC0-1.0, 0BSD, X11, MPL-2.0, MPL-1.0, MPL-1.1, MPL-2.0, OFL-1.1, Zlib fail-on-scopes: development, runtime allow-dependencies-licenses: 'pkg:npm/caniuse-lite' From 634640ff92c1dbea6763bfef491767a9cb3ca80a Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 8 Apr 2025 00:18:54 +0900 Subject: [PATCH 33/47] fix: add line breaks to jwt auth error messages --- src/service/passport/jwtAuthHandler.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js index faa039f50..2989cf65d 100644 --- a/src/service/passport/jwtAuthHandler.js +++ b/src/service/passport/jwtAuthHandler.js @@ -57,7 +57,7 @@ async function validateJwt(token, authorityUrl, clientID, expectedAudience) { return { verifiedPayload }; } catch (error) { - const errorMessage = `JWT validation failed: ${error.message}`; + const errorMessage = `JWT validation failed: ${error.message}\n`; console.error(errorMessage); return { error: errorMessage }; } @@ -77,18 +77,18 @@ const jwtAuthHandler = () => { const token = req.header("Authorization"); if (!token) { - return res.status(401).send("No token provided"); + return res.status(401).send("No token provided\n"); } const { clientID, authorityURL, expectedAudience } = jwtAuthMethod.jwtConfig; const audience = expectedAudience || clientID; if (!authorityURL) { - return res.status(500).send("OIDC authority URL is not configured"); + return res.status(500).send("OIDC authority URL is not configured\n"); } if (!clientID) { - return res.status(500).send("OIDC client ID is not configured"); + return res.status(500).send("OIDC client ID is not configured\n"); } const tokenParts = token.split(" "); From edf4f7daef2bf0236b019339eff72c9a5e5da8c2 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Thu, 10 Apr 2025 16:47:56 +0900 Subject: [PATCH 34/47] refactor(auth): convert jwt auth to API-only (remove jwt passport strategy) --- config.schema.json | 7 +++++ package-lock.json | 12 +------- package.json | 2 +- proxy.config.json | 41 +++++++++++++------------- src/config/index.js | 20 +++++++++++++ src/service/passport/index.js | 2 -- src/service/passport/jwt.js | 26 ---------------- src/service/passport/jwtAuthHandler.js | 4 +-- 8 files changed, 52 insertions(+), 62 deletions(-) delete mode 100644 src/service/passport/jwt.js diff --git a/config.schema.json b/config.schema.json index 771e83d0c..135018be9 100644 --- a/config.schema.json +++ b/config.schema.json @@ -78,6 +78,13 @@ "type": "object" } } + }, + "apiAuthentication": { + "description": "List of authentication sources for API endpoints. May be empty, in which case all endpoints are public.", + "type": "array", + "items": { + "$ref": "#/definitions/authentication" + } } }, "definitions": { diff --git a/package-lock.json b/package-lock.json index f580e9ff5..1a84fabc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "history": "5.3.0", "isomorphic-git": "^1.27.1", "jsonschema": "^1.4.1", + "jsonwebtoken": "^9.0.2", "jwk-to-pem": "^2.0.7", "load-plugin": "^6.0.0", "lodash": "^4.17.21", @@ -43,7 +44,6 @@ "parse-diff": "^0.11.1", "passport": "^0.7.0", "passport-activedirectory": "^1.0.4", - "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "perfect-scrollbar": "^1.5.5", "prop-types": "15.8.1", @@ -10504,16 +10504,6 @@ "url": "https://github.com/sponsors/jaredhanson" } }, - "node_modules/passport-jwt": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", - "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", - "license": "MIT", - "dependencies": { - "jsonwebtoken": "^9.0.0", - "passport-strategy": "^1.0.0" - } - }, "node_modules/passport-local": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", diff --git a/package.json b/package.json index b0896fcd4..d820d2c13 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "history": "5.3.0", "isomorphic-git": "^1.27.1", "jsonschema": "^1.4.1", + "jsonwebtoken": "^9.0.2", "jwk-to-pem": "^2.0.7", "load-plugin": "^6.0.0", "lodash": "^4.17.21", @@ -64,7 +65,6 @@ "parse-diff": "^0.11.1", "passport": "^0.7.0", "passport-activedirectory": "^1.0.4", - "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "perfect-scrollbar": "^1.5.5", "prop-types": "15.8.1", diff --git a/proxy.config.json b/proxy.config.json index 7fa4ef059..d494898e8 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -49,28 +49,19 @@ "baseDN": "", "searchBase": "" } - }, + }, { "type": "openidconnect", - "enabled": false, + "enabled": true, "oidcConfig": { - "issuer": "", - "clientID": "", - "clientSecret": "", - "authorizationURL": "", - "tokenURL": "", - "userInfoURL": "", - "callbackURL": "", - "scope": "" - } - }, - { - "type": "jwt", - "enabled": false, - "jwtConfig": { - "clientID": "", - "authorityURL": "", - "expectedAudience": "" + "issuer": "https://accounts.google.com", + "clientID": "1009968223893-u92qq6itk7ej5008o4174gjubs5lhorg.apps.googleusercontent.com", + "clientSecret": "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", + "authorizationURL": "https://accounts.google.com/o/oauth2/auth", + "tokenURL": "https://oauth2.googleapis.com/token", + "userInfoURL": "https://openidconnect.googleapis.com/v1/userinfo", + "callbackURL": "http://localhost:8080/api/auth/oidc/callback", + "scope": "openid email profile" } } ], @@ -120,5 +111,15 @@ "urlShortener": "", "contactEmail": "", "csrfProtection": true, - "plugins": [] + "plugins": [], + "apiAuthentication": [ + { + "type": "jwt", + "enabled": true, + "jwtConfig": { + "clientID": "1009968223893-u92qq6itk7ej5008o4174gjubs5lhorg.apps.googleusercontent.com", + "authorityURL": "https://accounts.google.com" + } + } + ] } diff --git a/src/config/index.js b/src/config/index.js index f9754859a..c00ca1de0 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -10,6 +10,7 @@ if (fs.existsSync(userSettingsPath)) { let _authorisedList = defaultSettings.authorisedList; let _database = defaultSettings.sink; let _authentication = defaultSettings.authentication; +let _apiAuthentication = defaultSettings.apiAuthentication; let _tempPassword = defaultSettings.tempPassword; let _proxyUrl = defaultSettings.proxyUrl; let _api = defaultSettings.api; @@ -88,6 +89,24 @@ const getAuthMethods = () => { return enabledAuthMethods; }; +/** + * Get the list of enabled authentication methods for API endpoints + * @return {Array} List of enabled authentication methods + */ +const getAPIAuthMethods = () => { + if (_userSettings !== null && _userSettings.apiAuthentication) { + _apiAuthentication = _userSettings.apiAuthentication; + } + + const enabledAuthMethods = _apiAuthentication.filter(auth => auth.enabled); + + if (enabledAuthMethods.length === 0) { + throw new Error("No authentication method enabled."); + } + + return enabledAuthMethods; +}; + // Log configuration to console const logConfiguration = () => { console.log(`authorisedList = ${JSON.stringify(getAuthorisedList())}`); @@ -205,6 +224,7 @@ exports.getAuthorisedList = getAuthorisedList; exports.getDatabase = getDatabase; exports.logConfiguration = logConfiguration; exports.getAuthMethods = getAuthMethods; +exports.getAPIAuthMethods = getAPIAuthMethods; exports.getTempPasswordConfig = getTempPasswordConfig; exports.getCookieSecret = getCookieSecret; exports.getSessionMaxAgeHours = getSessionMaxAgeHours; diff --git a/src/service/passport/index.js b/src/service/passport/index.js index 7126ae10f..533577d13 100644 --- a/src/service/passport/index.js +++ b/src/service/passport/index.js @@ -2,7 +2,6 @@ const passport = require("passport"); const local = require('./local'); const activeDirectory = require('./activeDirectory'); const oidc = require('./oidc'); -const jwt = require('./jwt'); const config = require('../../config'); // Allows obtaining strategy config function and type @@ -11,7 +10,6 @@ const authStrategies = { local: local, activedirectory: activeDirectory, openidconnect: oidc, - jwt: jwt, }; const configure = async () => { diff --git a/src/service/passport/jwt.js b/src/service/passport/jwt.js deleted file mode 100644 index f2b2dadb1..000000000 --- a/src/service/passport/jwt.js +++ /dev/null @@ -1,26 +0,0 @@ -const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); - -const type = "jwt"; - -const configure = (passport) => { - const JWT_SECRET = process.env.JWT_SECRET; - - if (!JWT_SECRET) { - console.log('JWT secret not provided. Skipping JWT strategy registration.'); - return; // Skip JWT registration if not configured - } - - const opts = { - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKey: JWT_SECRET, - }; - - passport.use('jwt', new JwtStrategy(opts, (jwtPayload, done) => { - if (!jwtPayload) return done(null, false); - return done(null, jwtPayload); - })); - - console.log('JWT strategy registered successfully.'); -}; - -module.exports = { configure, type }; diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js index 2989cf65d..7a4c62635 100644 --- a/src/service/passport/jwtAuthHandler.js +++ b/src/service/passport/jwtAuthHandler.js @@ -65,8 +65,8 @@ async function validateJwt(token, authorityUrl, clientID, expectedAudience) { const jwtAuthHandler = () => { return async (req, res, next) => { - const authMethods = require('../../config').getAuthMethods(); - const jwtAuthMethod = authMethods.find((method) => method.type.toLowerCase() === "jwt"); + const apiAuthMethods = require('../../config').getAPIAuthMethods(); + const jwtAuthMethod = apiAuthMethods.find((method) => method.type.toLowerCase() === "jwt"); if (!jwtAuthMethod) { return next(); } From cb3d1107fae7f53d4f4cc9dc66c49cc67aaddc1b Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 13 Apr 2025 14:17:53 +0900 Subject: [PATCH 35/47] fix(auth): remove jwt data from config and disable by default --- proxy.config.json | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/proxy.config.json b/proxy.config.json index d494898e8..37481de35 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -115,10 +115,15 @@ "apiAuthentication": [ { "type": "jwt", - "enabled": true, + "enabled": false, "jwtConfig": { - "clientID": "1009968223893-u92qq6itk7ej5008o4174gjubs5lhorg.apps.googleusercontent.com", - "authorityURL": "https://accounts.google.com" + "clientID": "", + "authorityURL": "", + "roleMapping": { + "admin": { + "": "" + } + } } } ] From fdaeb6b4c8e8c878886f847e21b3cfcb2e927fec Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 13 Apr 2025 14:20:14 +0900 Subject: [PATCH 36/47] feat(auth): add role mapping and assignment on jwt claims --- src/service/passport/jwtAuthHandler.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js index 7a4c62635..68131d3eb 100644 --- a/src/service/passport/jwtAuthHandler.js +++ b/src/service/passport/jwtAuthHandler.js @@ -63,6 +63,28 @@ async function validateJwt(token, authorityUrl, clientID, expectedAudience) { } } +/** + * Assign roles to the user based on the role mappings provided in the jwtConfig. + * + * If no role mapping is provided, the user will not have any roles assigned (i.e. user.admin = false). + * @param {*} roleMapping the role mapping configuration + * @param {*} payload the JWT payload + * @param {*} user the req.user object to assign roles to + */ +function assignRoles(roleMapping, payload, user) { + if (roleMapping) { + for (const role of Object.keys(roleMapping)) { + const claimValuePair = roleMapping[role]; + const claim = Object.keys(claimValuePair)[0]; + const value = claimValuePair[claim]; + + if (payload[claim] && payload[claim] === value) { + user[role] = true; + } + } + } +} + const jwtAuthHandler = () => { return async (req, res, next) => { const apiAuthMethods = require('../../config').getAPIAuthMethods(); @@ -80,7 +102,7 @@ const jwtAuthHandler = () => { return res.status(401).send("No token provided\n"); } - const { clientID, authorityURL, expectedAudience } = jwtAuthMethod.jwtConfig; + const { clientID, authorityURL, expectedAudience, roleMapping } = jwtAuthMethod.jwtConfig; const audience = expectedAudience || clientID; if (!authorityURL) { @@ -98,6 +120,8 @@ const jwtAuthHandler = () => { } req.user = verifiedPayload; + assignRoles(roleMapping, verifiedPayload, req.user); + return next(); } } From 5e1440ecbad7f9bed89348af2e4a2607450d1827 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 13 Apr 2025 18:15:49 +0900 Subject: [PATCH 37/47] test(auth): add test for getJwks helper --- test/testJwtAuthHandler.test.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 test/testJwtAuthHandler.test.js diff --git a/test/testJwtAuthHandler.test.js b/test/testJwtAuthHandler.test.js new file mode 100644 index 000000000..8268f70fd --- /dev/null +++ b/test/testJwtAuthHandler.test.js @@ -0,0 +1,29 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const axios = require('axios'); +const { getJwks } = require('../src/service/passport/jwtUtils'); + +describe('getJwks', () => { + it('should fetch JWKS keys from authority', async () => { + const jwksResponse = { keys: [{ kid: 'test-key', kty: 'RSA', n: 'abc', e: 'AQAB' }] }; + + const getStub = sinon.stub(axios, 'get'); + getStub.onFirstCall().resolves({ data: { jwks_uri: 'https://mock.com/jwks' } }); + getStub.onSecondCall().resolves({ data: jwksResponse }); + + const keys = await getJwks('https://mock.com'); + expect(keys).to.deep.equal(jwksResponse.keys); + + getStub.restore(); + }); + + it('should throw error if fetch fails', async () => { + const stub = sinon.stub(axios, 'get').rejects(new Error('Network fail')); + try { + await getJwks('https://fail.com'); + } catch (err) { + expect(err.message).to.equal('Failed to fetch JWKS'); + } + stub.restore(); + }); +}); From 24cba4d13f32403e92c6bba5eefe104dea948b5d Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 13 Apr 2025 18:16:30 +0900 Subject: [PATCH 38/47] chore(auth): move jwt util functions into own file for testing --- src/service/passport/jwtAuthHandler.js | 87 ------------------------ src/service/passport/jwtUtils.js | 92 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 87 deletions(-) create mode 100644 src/service/passport/jwtUtils.js diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js index 68131d3eb..6655b1b72 100644 --- a/src/service/passport/jwtAuthHandler.js +++ b/src/service/passport/jwtAuthHandler.js @@ -1,90 +1,3 @@ -const axios = require("axios"); -const jwt = require("jsonwebtoken"); -const jwkToPem = require("jwk-to-pem"); - -/** - * Obtain the JSON Web Key Set (JWKS) from the OIDC authority. - * @param {string} authorityUrl the OIDC authority URL. e.g. https://login.microsoftonline.com/{tenantId} - * @return {Promise} the JWKS keys - */ -async function getJwks(authorityUrl) { - try { - const { data } = await axios.get(`${authorityUrl}/.well-known/openid-configuration`); - const jwksUri = data.jwks_uri; - - const { data: jwks } = await axios.get(jwksUri); - return jwks.keys; - } catch (error) { - console.error("Error fetching JWKS:", error); - throw new Error("Failed to fetch JWKS"); - } -} - -/** - * Validate a JWT token using the OIDC configuration. - * @param {*} token the JWT token - * @param {*} authorityUrl the OIDC authority URL - * @param {*} clientID the OIDC client ID - * @param {*} expectedAudience the expected audience for the token - * @return {Promise} the verified payload or an error - */ -async function validateJwt(token, authorityUrl, clientID, expectedAudience) { - try { - const jwks = await getJwks(authorityUrl); - - const decodedHeader = await jwt.decode(token, { complete: true }); - if (!decodedHeader || !decodedHeader.header || !decodedHeader.header.kid) { - throw new Error("Invalid JWT: Missing key ID (kid)"); - } - - const { kid } = decodedHeader.header; - const jwk = jwks.find((key) => key.kid === kid); - if (!jwk) { - throw new Error("No matching key found in JWKS"); - } - - const pubKey = jwkToPem(jwk); - - const verifiedPayload = jwt.verify(token, pubKey, { - algorithms: ["RS256"], - issuer: authorityUrl, - audience: expectedAudience, - }); - - if (verifiedPayload.azp !== clientID) { - throw new Error("JWT client ID does not match"); - } - - return { verifiedPayload }; - } catch (error) { - const errorMessage = `JWT validation failed: ${error.message}\n`; - console.error(errorMessage); - return { error: errorMessage }; - } -} - -/** - * Assign roles to the user based on the role mappings provided in the jwtConfig. - * - * If no role mapping is provided, the user will not have any roles assigned (i.e. user.admin = false). - * @param {*} roleMapping the role mapping configuration - * @param {*} payload the JWT payload - * @param {*} user the req.user object to assign roles to - */ -function assignRoles(roleMapping, payload, user) { - if (roleMapping) { - for (const role of Object.keys(roleMapping)) { - const claimValuePair = roleMapping[role]; - const claim = Object.keys(claimValuePair)[0]; - const value = claimValuePair[claim]; - - if (payload[claim] && payload[claim] === value) { - user[role] = true; - } - } - } -} - const jwtAuthHandler = () => { return async (req, res, next) => { const apiAuthMethods = require('../../config').getAPIAuthMethods(); diff --git a/src/service/passport/jwtUtils.js b/src/service/passport/jwtUtils.js new file mode 100644 index 000000000..9c12ec756 --- /dev/null +++ b/src/service/passport/jwtUtils.js @@ -0,0 +1,92 @@ +const axios = require("axios"); +const jwt = require("jsonwebtoken"); +const jwkToPem = require("jwk-to-pem"); + +/** + * Obtain the JSON Web Key Set (JWKS) from the OIDC authority. + * @param {string} authorityUrl the OIDC authority URL. e.g. https://login.microsoftonline.com/{tenantId} + * @return {Promise} the JWKS keys + */ +async function getJwks(authorityUrl) { + try { + const { data } = await axios.get(`${authorityUrl}/.well-known/openid-configuration`); + const jwksUri = data.jwks_uri; + + const { data: jwks } = await axios.get(jwksUri); + return jwks.keys; + } catch (error) { + console.error("Error fetching JWKS:", error); + throw new Error("Failed to fetch JWKS"); + } +} + +/** + * Validate a JWT token using the OIDC configuration. + * @param {*} token the JWT token + * @param {*} authorityUrl the OIDC authority URL + * @param {*} clientID the OIDC client ID + * @param {*} expectedAudience the expected audience for the token + * @return {Promise} the verified payload or an error + */ +async function validateJwt(token, authorityUrl, clientID, expectedAudience) { + try { + const jwks = await getJwks(authorityUrl); + + const decodedHeader = await jwt.decode(token, { complete: true }); + if (!decodedHeader || !decodedHeader.header || !decodedHeader.header.kid) { + throw new Error("Invalid JWT: Missing key ID (kid)"); + } + + const { kid } = decodedHeader.header; + const jwk = jwks.find((key) => key.kid === kid); + if (!jwk) { + throw new Error("No matching key found in JWKS"); + } + + const pubKey = jwkToPem(jwk); + + const verifiedPayload = jwt.verify(token, pubKey, { + algorithms: ["RS256"], + issuer: authorityUrl, + audience: expectedAudience, + }); + + if (verifiedPayload.azp !== clientID) { + throw new Error("JWT client ID does not match"); + } + + return { verifiedPayload }; + } catch (error) { + const errorMessage = `JWT validation failed: ${error.message}\n`; + console.error(errorMessage); + return { error: errorMessage }; + } +} + +/** + * Assign roles to the user based on the role mappings provided in the jwtConfig. + * + * If no role mapping is provided, the user will not have any roles assigned (i.e. user.admin = false). + * @param {*} roleMapping the role mapping configuration + * @param {*} payload the JWT payload + * @param {*} user the req.user object to assign roles to + */ +function assignRoles(roleMapping, payload, user) { + if (roleMapping) { + for (const role of Object.keys(roleMapping)) { + const claimValuePair = roleMapping[role]; + const claim = Object.keys(claimValuePair)[0]; + const value = claimValuePair[claim]; + + if (payload[claim] && payload[claim] === value) { + user[role] = true; + } + } + } +} + +module.exports = { + getJwks, + validateJwt, + assignRoles, +}; From dad5beb782640719be58a17a8eeeeb32cdc7da44 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 13 Apr 2025 19:32:52 +0900 Subject: [PATCH 39/47] test(auth): add test for validateJwt helper function --- test/testJwtAuthHandler.test.js | 48 ++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/test/testJwtAuthHandler.test.js b/test/testJwtAuthHandler.test.js index 8268f70fd..a32155c41 100644 --- a/test/testJwtAuthHandler.test.js +++ b/test/testJwtAuthHandler.test.js @@ -1,7 +1,11 @@ const { expect } = require('chai'); const sinon = require('sinon'); const axios = require('axios'); -const { getJwks } = require('../src/service/passport/jwtUtils'); +const jwt = require('jsonwebtoken'); +const { jwkToBuffer } = require('jwk-to-pem'); + +const { getJwks, validateJwt } = require('../src/service/passport/jwtUtils'); +const { jwtAuthHandler } = require('../src/service/passport/jwtAuthHandler'); describe('getJwks', () => { it('should fetch JWKS keys from authority', async () => { @@ -27,3 +31,45 @@ describe('getJwks', () => { stub.restore(); }); }); + +describe('validateJwt', () => { + let decodeStub, verifyStub, pemStub, getJwksStub; + + beforeEach(() => { + const jwksResponse = { keys: [{ kid: 'test-key', kty: 'RSA', n: 'abc', e: 'AQAB' }] }; + const getStub = sinon.stub(axios, 'get'); + getStub.onFirstCall().resolves({ data: { jwks_uri: 'https://mock.com/jwks' } }); + getStub.onSecondCall().resolves({ data: jwksResponse }); + + getJwksStub = sinon.stub().resolves(jwksResponse.keys); + decodeStub = sinon.stub(jwt, 'decode'); + verifyStub = sinon.stub(jwt, 'verify'); + pemStub = sinon.stub(jwkToBuffer); + + pemStub.returns('fake-public-key'); + getJwksStub.returns(jwksResponse.keys); + }); + + afterEach(() => sinon.restore()); + + it('should validate a correct JWT', async () => { + const mockJwk = { kid: '123', kty: 'RSA', n: 'abc', e: 'AQAB' }; + const mockPem = 'fake-public-key'; + + decodeStub.returns({ header: { kid: '123' } }); + getJwksStub.resolves([mockJwk]); + pemStub.returns(mockPem); + verifyStub.returns({ azp: 'client-id', sub: 'user123' }); + + const { verifiedPayload } = await validateJwt('fake.token.here', 'https://issuer.com', 'client-id', 'client-id', getJwksStub); + expect(verifiedPayload.sub).to.equal('user123'); + }); + + it('should return error if JWT invalid', async () => { + decodeStub.returns(null); // Simulate broken token + + const { error } = await validateJwt('bad.token', 'https://issuer.com', 'client-id', 'client-id', getJwksStub); + expect(error).to.include('Invalid JWT'); + }); +}); + From 407cb859bc3a82101a8ac7fc981bffe81446d683 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 13 Apr 2025 20:46:49 +0900 Subject: [PATCH 40/47] test(auth): add test for assignRoles helper function --- test/testJwtAuthHandler.test.js | 42 +++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/test/testJwtAuthHandler.test.js b/test/testJwtAuthHandler.test.js index a32155c41..12cbfbd74 100644 --- a/test/testJwtAuthHandler.test.js +++ b/test/testJwtAuthHandler.test.js @@ -4,8 +4,8 @@ const axios = require('axios'); const jwt = require('jsonwebtoken'); const { jwkToBuffer } = require('jwk-to-pem'); -const { getJwks, validateJwt } = require('../src/service/passport/jwtUtils'); -const { jwtAuthHandler } = require('../src/service/passport/jwtAuthHandler'); +const { assignRoles, getJwks, validateJwt } = require('../src/service/passport/jwtUtils'); +const jwtAuthHandler = require('../src/service/passport/jwtAuthHandler'); describe('getJwks', () => { it('should fetch JWKS keys from authority', async () => { @@ -73,3 +73,41 @@ describe('validateJwt', () => { }); }); +describe('assignRoles', () => { + it('should assign admin role based on claim', () => { + const user = { username: 'admin-user' }; + const payload = { admin: 'admin' }; + const mapping = { admin: { 'admin': 'admin' } }; + + assignRoles(mapping, payload, user); + expect(user.admin).to.be.true; + }); + + it('should assign multiple roles based on claims', () => { + const user = { username: 'multi-role-user' }; + const payload = { 'custom-claim-admin': 'custom-value', 'editor': 'editor' }; + const mapping = { admin: { 'custom-claim-admin': 'custom-value' }, editor: { 'editor': 'editor' } }; + + assignRoles(mapping, payload, user); + expect(user.admin).to.be.true; + expect(user.editor).to.be.true; + }); + + it('should not assign role if claim mismatch', () => { + const user = { username: 'basic-user' }; + const payload = { admin: 'nope' }; + const mapping = { admin: { admin: 'admin' } }; + + assignRoles(mapping, payload, user); + expect(user.admin).to.be.undefined; + }); + + it('should not assign role if no mapping provided', () => { + const user = { username: 'no-role-user' }; + const payload = { admin: 'admin' }; + + assignRoles(null, payload, user); + expect(user.admin).to.be.undefined; + }); +}); + From 420be8d1b8bd8120f005289430cdbe7dd627089c Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 13 Apr 2025 21:35:37 +0900 Subject: [PATCH 41/47] test(auth): add tests for jwtAuthHandler --- src/service/passport/jwtAuthHandler.js | 15 +++++- src/service/passport/jwtUtils.js | 5 +- test/testJwtAuthHandler.test.js | 73 ++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js index 6655b1b72..4d107f220 100644 --- a/src/service/passport/jwtAuthHandler.js +++ b/src/service/passport/jwtAuthHandler.js @@ -1,6 +1,17 @@ -const jwtAuthHandler = () => { +const { assignRoles, validateJwt } = require('./jwtUtils'); + +/** + * Middleware function to handle JWT authentication. + * @param {*} overrideConfig optional configuration to override the default JWT configuration (e.g. for testing) + * @returns {Function} the middleware function + */ +const jwtAuthHandler = (overrideConfig = null) => { return async (req, res, next) => { - const apiAuthMethods = require('../../config').getAPIAuthMethods(); + const apiAuthMethods = + overrideConfig + ? [{ type: "jwt", jwtConfig: overrideConfig }] + : require('../../config').getAPIAuthMethods(); + const jwtAuthMethod = apiAuthMethods.find((method) => method.type.toLowerCase() === "jwt"); if (!jwtAuthMethod) { return next(); diff --git a/src/service/passport/jwtUtils.js b/src/service/passport/jwtUtils.js index 9c12ec756..45bda4cc9 100644 --- a/src/service/passport/jwtUtils.js +++ b/src/service/passport/jwtUtils.js @@ -26,11 +26,12 @@ async function getJwks(authorityUrl) { * @param {*} authorityUrl the OIDC authority URL * @param {*} clientID the OIDC client ID * @param {*} expectedAudience the expected audience for the token + * @param {*} getJwksInject the getJwks function to use (for dependency injection). Defaults to the built-in getJwks function. * @return {Promise} the verified payload or an error */ -async function validateJwt(token, authorityUrl, clientID, expectedAudience) { +async function validateJwt(token, authorityUrl, clientID, expectedAudience, getJwksInject = getJwks) { try { - const jwks = await getJwks(authorityUrl); + const jwks = await getJwksInject(authorityUrl); const decodedHeader = await jwt.decode(token, { complete: true }); if (!decodedHeader || !decodedHeader.header || !decodedHeader.header.kid) { diff --git a/test/testJwtAuthHandler.test.js b/test/testJwtAuthHandler.test.js index 12cbfbd74..eec7a3464 100644 --- a/test/testJwtAuthHandler.test.js +++ b/test/testJwtAuthHandler.test.js @@ -111,3 +111,76 @@ describe('assignRoles', () => { }); }); +describe('jwtAuthHandler', () => { + let req, res, next, jwtConfig, validVerifyResponse; + + beforeEach(() => { + req = { header: sinon.stub(), isAuthenticated: sinon.stub(), user: {} }; + res = { status: sinon.stub().returnsThis(), send: sinon.stub() }; + next = sinon.stub(); + + jwtConfig = { + clientID: 'client-id', + authorityURL: 'https://accounts.google.com', + expectedAudience: 'expected-audience', + roleMapping: { 'admin': { 'admin': 'admin' } } + }; + + validVerifyResponse = { + header: { kid: '123' }, + azp: 'client-id', + sub: 'user123', + admin: 'admin' + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should call next if user is authenticated', async () => { + req.isAuthenticated.returns(true); + await jwtAuthHandler()(req, res, next); + expect(next.calledOnce).to.be.true; + }); + + it('should return 401 if no token provided', async () => { + req.header.returns(null); + await jwtAuthHandler()(req, res, next); + + expect(res.status.calledWith(401)).to.be.true; + expect(res.send.calledWith('No token provided\n')).to.be.true; + }); + + it('should return 500 if authorityURL not configured', async () => { + req.header.returns('Bearer fake-token'); + jwtConfig.authorityURL = null; + sinon.stub(jwt, 'verify').returns(validVerifyResponse); + + await jwtAuthHandler(jwtConfig)(req, res, next); + + expect(res.status.calledWith(500)).to.be.true; + expect(res.send.calledWith('OIDC authority URL is not configured\n')).to.be.true; + }); + + it('should return 500 if clientID not configured', async () => { + req.header.returns('Bearer fake-token'); + jwtConfig.clientID = null; + sinon.stub(jwt, 'verify').returns(validVerifyResponse); + + await jwtAuthHandler(jwtConfig)(req, res, next); + + expect(res.status.calledWith(500)).to.be.true; + expect(res.send.calledWith('OIDC client ID is not configured\n')).to.be.true; + }); + + it('should return 401 if JWT validation fails', async () => { + req.header.returns('Bearer fake-token'); + sinon.stub(jwt, 'verify').throws(new Error('Invalid token')); + + await jwtAuthHandler(jwtConfig)(req, res, next); + + expect(res.status.calledWith(401)).to.be.true; + expect(res.send.calledWithMatch(/JWT validation failed:/)).to.be.true; + }); +}); From 5bdcd69e395cd920eb59fc397e79e4f3b8c071fa Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Wed, 16 Apr 2025 12:30:00 +0900 Subject: [PATCH 42/47] chore: add missing jwtConfig parameter (optional) --- proxy.config.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/proxy.config.json b/proxy.config.json index 37481de35..514e6bcd3 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -117,8 +117,7 @@ "type": "jwt", "enabled": false, "jwtConfig": { - "clientID": "", - "authorityURL": "", + "expectedAudience": "", "roleMapping": { "admin": { "": "" From aee4b4f5a584b23d8b1938ab46a8ba341115d6c9 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Wed, 16 Apr 2025 14:56:58 +0900 Subject: [PATCH 43/47] fix: fix failing tests --- src/config/index.ts | 2 +- src/service/passport/jwtAuthHandler.js | 2 +- test/testJwtAuthHandler.test.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index 55a991405..633cff9e6 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -110,7 +110,7 @@ export const getAPIAuthMethods = (): Authentication[] => { const enabledAuthMethods = _apiAuthentication.filter(auth => auth.enabled); if (enabledAuthMethods.length === 0) { - throw new Error("No authentication method enabled."); + console.log("Warning: No authentication method enabled for API endpoints."); } return enabledAuthMethods; diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js index 4d107f220..0cfb95412 100644 --- a/src/service/passport/jwtAuthHandler.js +++ b/src/service/passport/jwtAuthHandler.js @@ -13,7 +13,7 @@ const jwtAuthHandler = (overrideConfig = null) => { : require('../../config').getAPIAuthMethods(); const jwtAuthMethod = apiAuthMethods.find((method) => method.type.toLowerCase() === "jwt"); - if (!jwtAuthMethod) { + if (!overrideConfig && (!jwtAuthMethod || !jwtAuthMethod.enabled)) { return next(); } diff --git a/test/testJwtAuthHandler.test.js b/test/testJwtAuthHandler.test.js index eec7a3464..326bce163 100644 --- a/test/testJwtAuthHandler.test.js +++ b/test/testJwtAuthHandler.test.js @@ -146,7 +146,7 @@ describe('jwtAuthHandler', () => { it('should return 401 if no token provided', async () => { req.header.returns(null); - await jwtAuthHandler()(req, res, next); + await jwtAuthHandler(jwtConfig)(req, res, next); expect(res.status.calledWith(401)).to.be.true; expect(res.send.calledWith('No token provided\n')).to.be.true; From 583616a532352d1bf27d815ba58727ed902e34ee Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Wed, 16 Apr 2025 15:45:00 +0900 Subject: [PATCH 44/47] fix: remove unneeded oidc config params and values --- proxy.config.json | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/proxy.config.json b/proxy.config.json index 07a1d4fb2..5f3e73cfb 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -52,16 +52,13 @@ }, { "type": "openidconnect", - "enabled": true, + "enabled": false, "oidcConfig": { - "issuer": "https://accounts.google.com", - "clientID": "1009968223893-u92qq6itk7ej5008o4174gjubs5lhorg.apps.googleusercontent.com", - "clientSecret": "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", - "authorizationURL": "https://accounts.google.com/o/oauth2/auth", - "tokenURL": "https://oauth2.googleapis.com/token", - "userInfoURL": "https://openidconnect.googleapis.com/v1/userinfo", - "callbackURL": "http://localhost:8080/api/auth/oidc/callback", - "scope": "openid email profile" + "issuer": "", + "clientID": "", + "clientSecret": "", + "callbackURL": "", + "scope": "" } } ], From f31bc6de0703c643136b5ae7d0ee7c0cf7abd489 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Wed, 16 Apr 2025 15:59:08 +0900 Subject: [PATCH 45/47] fix: fix linter and test issues --- package.json | 2 +- src/service/passport/jwtAuthHandler.js | 2 +- src/service/passport/oidc.js | 5 ++--- test/testJwtAuthHandler.test.js | 11 +++++++++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 77c368cf2..cf302235f 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "moment": "^2.29.4", "mongodb": "^5.0.0", "nodemailer": "^6.6.1", - "openid-client": "^6.3.1", + "openid-client": "^6.4.2", "parse-diff": "^0.11.1", "passport": "^0.7.0", "passport-activedirectory": "^1.0.4", diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js index 0cfb95412..32c81304d 100644 --- a/src/service/passport/jwtAuthHandler.js +++ b/src/service/passport/jwtAuthHandler.js @@ -3,7 +3,7 @@ const { assignRoles, validateJwt } = require('./jwtUtils'); /** * Middleware function to handle JWT authentication. * @param {*} overrideConfig optional configuration to override the default JWT configuration (e.g. for testing) - * @returns {Function} the middleware function + * @return {Function} the middleware function */ const jwtAuthHandler = (overrideConfig = null) => { return async (req, res, next) => { diff --git a/src/service/passport/oidc.js b/src/service/passport/oidc.js index 6c9cd00a7..2e2cd41d3 100644 --- a/src/service/passport/oidc.js +++ b/src/service/passport/oidc.js @@ -3,9 +3,6 @@ const db = require('../../db'); let type; const configure = async (passport) => { - const openIdClient = await import('openid-client'); - const { Strategy } = await import('openid-client/passport'); - const authMethods = require('../../config').getAuthMethods(); const oidcConfig = authMethods.find((method) => method.type.toLowerCase() === "openidconnect")?.oidcConfig; const { issuer, clientID, clientSecret, callbackURL, scope } = oidcConfig; @@ -15,6 +12,8 @@ const configure = async (passport) => { } const server = new URL(issuer); + const openIdClient = await import('openid-client'); + const { Strategy } = await import('openid-client/passport'); try { const config = await openIdClient.discovery(server, clientID, clientSecret); diff --git a/test/testJwtAuthHandler.test.js b/test/testJwtAuthHandler.test.js index 326bce163..af2bb1bb2 100644 --- a/test/testJwtAuthHandler.test.js +++ b/test/testJwtAuthHandler.test.js @@ -33,7 +33,10 @@ describe('getJwks', () => { }); describe('validateJwt', () => { - let decodeStub, verifyStub, pemStub, getJwksStub; + let decodeStub; + let verifyStub; + let pemStub; + let getJwksStub; beforeEach(() => { const jwksResponse = { keys: [{ kid: 'test-key', kty: 'RSA', n: 'abc', e: 'AQAB' }] }; @@ -112,7 +115,11 @@ describe('assignRoles', () => { }); describe('jwtAuthHandler', () => { - let req, res, next, jwtConfig, validVerifyResponse; + let req; + let res; + let next; + let jwtConfig; + let validVerifyResponse; beforeEach(() => { req = { header: sinon.stub(), isAuthenticated: sinon.stub(), user: {} }; From 412b209328a6b94164ae5a9dc5d70efa7edfba05 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Wed, 16 Apr 2025 16:27:19 +0900 Subject: [PATCH 46/47] fix: e2e test fail due to route refactor (admin -> dashboard) --- cypress/e2e/autoApproved.cy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/e2e/autoApproved.cy.js b/cypress/e2e/autoApproved.cy.js index ae67f3ecd..8d830af6b 100644 --- a/cypress/e2e/autoApproved.cy.js +++ b/cypress/e2e/autoApproved.cy.js @@ -45,7 +45,7 @@ describe('Auto-Approved Push Test', () => { }); it('should display auto-approved message and verify tooltip contains the expected timestamp', () => { - cy.visit('/admin/push/123'); + cy.visit('/dashboard/push/123'); cy.wait('@getPush'); From 336e51df3f7e6be1c0af9cd8e46893856a8947df Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Wed, 16 Apr 2025 17:26:46 +0900 Subject: [PATCH 47/47] fix: e2e test fail (login required) --- cypress/e2e/autoApproved.cy.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cypress/e2e/autoApproved.cy.js b/cypress/e2e/autoApproved.cy.js index 8d830af6b..65d9d65a1 100644 --- a/cypress/e2e/autoApproved.cy.js +++ b/cypress/e2e/autoApproved.cy.js @@ -2,6 +2,8 @@ import moment from 'moment'; describe('Auto-Approved Push Test', () => { beforeEach(() => { + cy.login('admin', 'admin'); + cy.intercept('GET', '/api/v1/push/123', { statusCode: 200, body: {