From cb3d1107fae7f53d4f4cc9dc66c49cc67aaddc1b Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 13 Apr 2025 14:17:53 +0900 Subject: [PATCH 01/20] 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 02/20] 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 03/20] 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 04/20] 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 05/20] 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 06/20] 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 07/20] 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 08/20] 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 09/20] 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 10/20] 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 11/20] 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 12/20] 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 13/20] 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: { From 27cfb07cd89fdbb52d3e88b2f809fa197da7cbd6 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 13 Jun 2025 23:00:43 +0900 Subject: [PATCH 14/20] chore: update package.json scripts --- package.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cf302235f..5a70ce61a 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,13 @@ "clientinstall": "npm install --prefix client", "server": "tsx index.ts", "start": "concurrently \"npm run server\" \"npm run client\"", - "build": "vite build", + "build": "npm run build-ui && npm run build-lib", + "test": "NODE_ENV=test ts-mocha './test/**/*.test.js' --exit", "build-ts": "tsc", - "test": "NODE_ENV=test ts-mocha './test/*.js' --exit", + "build-ui": "vite build", + "build-lib": "./scripts/build-for-publish.sh", + "restore-lib": "./scripts/undo-build.sh", + "check-types": "tsc", "test-coverage": "nyc npm run test", "test-coverage-ci": "nyc --reporter=lcovonly --reporter=text npm run test", "prepare": "node ./scripts/prepare.js", From b808b61a1645dd4d920715c93c3f42e5fef60e02 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 14 Jun 2025 02:02:15 +0900 Subject: [PATCH 15/20] chore: improve oidc error handling --- src/service/passport/oidc.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/service/passport/oidc.js b/src/service/passport/oidc.js index 52928460b..7e2aa5ee0 100644 --- a/src/service/passport/oidc.js +++ b/src/service/passport/oidc.js @@ -17,10 +17,16 @@ const configure = async (passport) => { } const server = new URL(issuer); + let config; try { - const config = await discovery(server, clientID, clientSecret); + config = await discovery(server, clientID, clientSecret); + } catch (error) { + console.error('Error during OIDC discovery:', error); + throw new Error('OIDC setup error (discovery): ' + error.message); + } + try { const strategy = new Strategy({ callbackURL, config, scope }, async (tokenSet, done) => { // Validate token sub for added security const idTokenClaims = tokenSet.claims(); @@ -56,8 +62,8 @@ const configure = async (passport) => { return passport; } catch (error) { - console.error('OIDC configuration failed:', error); - throw error; + console.error('Error during OIDC passport setup:', error); + throw new Error('OIDC setup error (strategy): ' + error.message); } }; From dccb6c4ad624b9e3c9f29768e9e4498d339aa380 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 14 Jun 2025 02:02:43 +0900 Subject: [PATCH 16/20] chore: improve /repo error handling --- src/service/routes/repo.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/service/routes/repo.js b/src/service/routes/repo.js index f43181a00..cc70cec16 100644 --- a/src/service/routes/repo.js +++ b/src/service/routes/repo.js @@ -110,7 +110,7 @@ router.delete('/:name/user/push/:username', async (req, res) => { }); router.delete('/:name/delete', async (req, res) => { - if (req.user.admin) { + if (req.user && req.user.admin) { const repoName = req.params.name; await db.deleteRepo(repoName); @@ -124,6 +124,13 @@ router.delete('/:name/delete', async (req, res) => { router.post('/', async (req, res) => { if (req.user && req.user.admin) { + if (!req.body.name) { + res.status(400).send({ + message: 'Repository name is required', + }); + return; + } + const repo = await db.getRepo(req.body.name); if (repo) { res.status(409).send({ From 7b8df5f3c9bd35ab389e8f91ec4e6a150e6155ba Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 15 Jun 2025 19:28:40 +0900 Subject: [PATCH 17/20] test: add extra unit test for invalid login --- cypress/e2e/login.cy.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cypress/e2e/login.cy.js b/cypress/e2e/login.cy.js index 25d80e438..27ee07607 100644 --- a/cypress/e2e/login.cy.js +++ b/cypress/e2e/login.cy.js @@ -31,6 +31,16 @@ describe('Login page', () => { cy.url().should('include', '/dashboard/repo'); }) + it('should show an error snackbar on invalid login', () => { + cy.get('[data-test="username"]').type('wronguser'); + cy.get('[data-test="password"]').type('wrongpass'); + cy.get('[data-test="login"]').click(); + + cy.get('.MuiSnackbarContent-message') + .should('be.visible') + .and('contain', 'You entered an invalid username or password...'); + }); + describe('OIDC login button', () => { it('should exist', () => { cy.get('[data-test="oidc-login"]').should('exist'); From 3d420a2907257e48d16d3e0cc62f89ee050bd270 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 15 Jun 2025 20:46:29 +0900 Subject: [PATCH 18/20] docs: add authentication doc page --- src/service/passport/index.js | 1 - website/docs/configuration/authentication.mdx | 136 ++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 website/docs/configuration/authentication.mdx diff --git a/src/service/passport/index.js b/src/service/passport/index.js index b0712d510..72918282f 100644 --- a/src/service/passport/index.js +++ b/src/service/passport/index.js @@ -16,7 +16,6 @@ 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/website/docs/configuration/authentication.mdx b/website/docs/configuration/authentication.mdx new file mode 100644 index 000000000..2e29315c8 --- /dev/null +++ b/website/docs/configuration/authentication.mdx @@ -0,0 +1,136 @@ +--- +title: Authentication +description: How to customize authentication methods for UI login and API +--- + +GitProxy allows setting up various auth methods for both UI users, and the backend API. + +### Where to Configure Auth Methods + +Auth methods can be configured in [proxy.config.json](/proxy.config.json). The `authentication` array allows setting UI authentication, for user login and management purposes. The `apiAuthentication` array allows setting up an _optional_ authentication layer for extra API security. + +### Default Configuration + +By default, GitProxy has a **local** UI authentication method enabled. Although not particularly secure, this allows logging in with simple user/password combinations for signup and login. + +### Supported Methods + +#### UI Auth + +Currently, GitProxy supports three differemt methods for user creation and login: +- Local +- ActiveDirectory +- OIDC + +Each of these has its own specific config entry with necessary parameters for setting up the method. + +#### API Auth + +GitProxy also supports protecting API endpoints with extra auth layers. Currently, the only available method is JWT - but this can be easily extended to use your own methods such as GitHub login and more. + +### Sample Configuration - UI + +#### Active Directory + +A default, empty setup for OIDC is already present in [proxy.config.json](/proxy.config.json): + +```json +{ + "type": "ActiveDirectory", + "enabled": false, + "adminGroup": "", + "userGroup": "", + "domain": "", + "adConfig": { + "url": "", + "baseDN": "", + "searchBase": "" + } +}, +``` + +#### OIDC + +A default, empty setup for OIDC is already present in [proxy.config.json](/proxy.config.json). You can fill this in with your required parameters. Here's an example using Google as a login provider: + +```json +"authentication": [ + { + "type": "openidconnect", + "enabled": true, + "oidcConfig": { + "issuer": "https://accounts.google.com", + "clientID": "", + "clientSecret": "", + "callbackURL": "http://localhost:8080/api/auth/oidc/callback", + "scope": "email profile" + } + } +], +``` + +Notice that the `callbackURL` (`/api/auth/oidc/callback`) must be set both in your provider and this config file for the flow to work. + +### Sample Configuration - API + +#### JWT + +JWT auth is ideal for using the GitProxy API along with CI tools and automation. It allows verifying credentials and admin permissions to use GitProxy securely in scripts, CI/CD pipelines and more. + +You will need an existing OIDC setup to release valid JWT tokens. + +**Warning: GitProxy does not provide/release JWT tokens for API validation.** Your service (configured through `jwtConfig`) will have to do this on its own. For example, it could be an app that allows users to log in through Google, and releases a JWT `access_token` with a one hour expiry date. + +If the `jwt` auth method is enabled in the config, you'll notice that UI requests are no longer working. This is expected since the endpoints require a valid JWT to proceed. Once the `Bearer: ` authorization header is added, you should be able to access the endpoints as usual. + +##### JWT Role Mapping + +JWT auth also allows authenticating to specific in-app roles by using JWT `claims`. In the following sample config, Google JWT tokens that contain the following `claim` (`name: "John Doe"`) will be assigned the in-app admin role: + +```json +"apiAuthentication": [ + { + "type": "jwt", + "enabled": true, + "jwtConfig": { + "clientID": "", + "authorityURL": "https://accounts.google.com", + "expectedAudience": "", + "roleMapping": { + "admin": { + "name": "John Doe" + } + } + } + } +], +``` + +In other words, your JWT token provider can define an arbitrary `claim` that can be mapped to any app role you want, such as `admin`, or a custom role such as `ci-only`. This allows for granular access control for automation solutions using GitProxy. + +Note that if the `expectedAudience` is missing, it will be set to the client ID. + +### Adding your own methods + +You can add new UI auth methods by extending the [passport.js configuration file](/src/service/passport/local.js) with your desired method. You'll have to define a module and then add it to the `authStrategies` map: + +```js +const local = require('./local'); +const activeDirectory = require('./activeDirectory'); +const oidc = require('./oidc'); + +const authStrategies = { + local: local, + activedirectory: activeDirectory, + openidconnect: oidc, +}; +``` + +Check out the files in [src/service/passport](/src/service/passport) for examples on how to define the specific `configure()` functions for each method: +- [Local](/src/service/passport/local.js) +- [ActiveDirectory](/src/service/passport/activeDirectory.js) +- [OIDC](/src/service/passport/oidc.js) + +### Questions? + +If you have any questions, feel free to [open a discussion](https://github.com/finos/git-proxy/discussions). \ No newline at end of file From 31cba39512f70b79a87a9e0b5050d4560d16d4bb Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 15 Jun 2025 20:52:24 +0900 Subject: [PATCH 19/20] docs: fix broken links --- website/docs/configuration/authentication.mdx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/website/docs/configuration/authentication.mdx b/website/docs/configuration/authentication.mdx index 2e29315c8..626c4f746 100644 --- a/website/docs/configuration/authentication.mdx +++ b/website/docs/configuration/authentication.mdx @@ -7,7 +7,7 @@ GitProxy allows setting up various auth methods for both UI users, and the backe ### Where to Configure Auth Methods -Auth methods can be configured in [proxy.config.json](/proxy.config.json). The `authentication` array allows setting UI authentication, for user login and management purposes. The `apiAuthentication` array allows setting up an _optional_ authentication layer for extra API security. +Auth methods can be configured in [proxy.config.json](https://github.com/finos/git-proxy/blob/main/proxy.config.json). The `authentication` array allows setting UI authentication, for user login and management purposes. The `apiAuthentication` array allows setting up an _optional_ authentication layer for extra API security. ### Default Configuration @@ -32,7 +32,7 @@ GitProxy also supports protecting API endpoints with extra auth layers. Currentl #### Active Directory -A default, empty setup for OIDC is already present in [proxy.config.json](/proxy.config.json): +A default, empty setup for OIDC is already present in [proxy.config.json](https://github.com/finos/git-proxy/blob/main/proxy.config.json): ```json { @@ -51,7 +51,7 @@ A default, empty setup for OIDC is already present in [proxy.config.json](/proxy #### OIDC -A default, empty setup for OIDC is already present in [proxy.config.json](/proxy.config.json). You can fill this in with your required parameters. Here's an example using Google as a login provider: +A default, empty setup for OIDC is already present in [proxy.config.json](https://github.com/finos/git-proxy/blob/main/proxy.config.json). You can fill this in with your required parameters. Here's an example using Google as a login provider: ```json "authentication": [ @@ -112,7 +112,7 @@ Note that if the `expectedAudience` is missing, it will be set to the client ID. ### Adding your own methods -You can add new UI auth methods by extending the [passport.js configuration file](/src/service/passport/local.js) with your desired method. You'll have to define a module and then add it to the `authStrategies` map: +You can add new UI auth methods by extending the [passport.js configuration file](https://github.com/finos/git-proxy/blob/main/src/service/passport/local.js) with your desired method. You'll have to define a module and then add it to the `authStrategies` map: ```js const local = require('./local'); @@ -126,10 +126,10 @@ const authStrategies = { }; ``` -Check out the files in [src/service/passport](/src/service/passport) for examples on how to define the specific `configure()` functions for each method: -- [Local](/src/service/passport/local.js) -- [ActiveDirectory](/src/service/passport/activeDirectory.js) -- [OIDC](/src/service/passport/oidc.js) +Check out the files in [src/service/passport](https://github.com/finos/git-proxy/blob/main/src/service/passport) for examples on how to define the specific `configure()` functions for each method: +- [Local](https://github.com/finos/git-proxy/blob/main/src/service/passport/local.js) +- [ActiveDirectory](https://github.com/finos/git-proxy/blob/main/src/service/passport/activeDirectory.js) +- [OIDC](https://github.com/finos/git-proxy/blob/main/src/service/passport/oidc.js) ### Questions? From 25f00659f71db4df838b22767485132152227495 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 15 Jun 2025 22:17:31 +0900 Subject: [PATCH 20/20] fix: make error handling more descriptive and catch JWT config error --- src/ui/services/git-push.js | 8 +++++--- src/ui/services/repo.js | 19 +++++++++++++------ src/ui/services/user.js | 16 ++++++++++++---- .../components/PushesTable.jsx | 5 +++-- .../RepoList/Components/Repositories.jsx | 4 +++- src/ui/views/UserList/Components/UserList.jsx | 5 +++-- 6 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/ui/services/git-push.js b/src/ui/services/git-push.js index df8f6f354..f9100fd02 100644 --- a/src/ui/services/git-push.js +++ b/src/ui/services/git-push.js @@ -45,6 +45,7 @@ const getPushes = async ( setData, setAuth, setIsError, + setErrorMessage, query = { blocked: true, canceled: false, @@ -60,15 +61,16 @@ const getPushes = async ( .then((response) => { const data = response.data; setData(data); - setIsLoading(false); }) .catch((error) => { - setIsLoading(false); + setIsError(true); if (error.response && error.response.status === 401) { setAuth(false); + setErrorMessage('Failed to authorize user. If JWT auth is enabled, please check your configuration or disable it.'); } else { - setIsError(true); + setErrorMessage(`Error fetching pushes: ${error.response.data.message}`); } + }).finally(() => { setIsLoading(false); }); }; diff --git a/src/ui/services/repo.js b/src/ui/services/repo.js index 2ac0bc98d..27d898c75 100644 --- a/src/ui/services/repo.js +++ b/src/ui/services/repo.js @@ -33,7 +33,14 @@ class DupUserValidationError extends Error { } } -const getRepos = async (setIsLoading, setData, setAuth, setIsError, query = {}) => { +const getRepos = async ( + setIsLoading, + setData, + setAuth, + setIsError, + setErrorMessage, + query = {}, +) => { const url = new URL(`${baseUrl}/repo`); url.search = new URLSearchParams(query); setIsLoading(true); @@ -41,15 +48,16 @@ const getRepos = async (setIsLoading, setData, setAuth, setIsError, query = {}) .then((response) => { const data = response.data; setData(data); - setIsLoading(false); }) .catch((error) => { - setIsLoading(false); + setIsError(true); if (error.response && error.response.status === 401) { setAuth(false); + setErrorMessage('Failed to authorize user. If JWT auth is enabled, please check your configuration or disable it.'); } else { - setIsError(true); + setErrorMessage(`Error fetching repositories: ${error.response.data.message}`); } + }).finally(() => { setIsLoading(false); }); }; @@ -61,15 +69,14 @@ const getRepo = async (setIsLoading, setData, setAuth, setIsError, id) => { .then((response) => { const data = response.data; setData(data); - setIsLoading(false); }) .catch((error) => { - setIsLoading(false); if (error.response && error.response.status === 401) { setAuth(false); } else { setIsError(true); } + }).finally(() => { setIsLoading(false); }); }; diff --git a/src/ui/services/user.js b/src/ui/services/user.js index cab1dc3ea..bdccdfc45 100644 --- a/src/ui/services/user.js +++ b/src/ui/services/user.js @@ -44,7 +44,14 @@ const getUser = async (setIsLoading, setData, setAuth, setIsError, id = null) => }); }; -const getUsers = async (setIsLoading, setData, setAuth, setIsError, query = {}) => { +const getUsers = async ( + setIsLoading, + setData, + setAuth, + setIsError, + setErrorMessage, + query = {}, +) => { const url = new URL(`${baseUrl}/api/v1/user`); url.search = new URLSearchParams(query); setIsLoading(true); @@ -52,15 +59,16 @@ const getUsers = async (setIsLoading, setData, setAuth, setIsError, query = {}) .then((response) => { const data = response.data; setData(data); - setIsLoading(false); }) .catch((error) => { - setIsLoading(false); + setIsError(true); if (error.response && error.response.status === 401) { setAuth(false); + setErrorMessage('Failed to authorize user. If JWT auth is enabled, please check your configuration or disable it.'); } else { - setIsError(true); + setErrorMessage(`Error fetching users: ${error.response.data.message}`); } + }).finally(() => { setIsLoading(false); }); }; diff --git a/src/ui/views/OpenPushRequests/components/PushesTable.jsx b/src/ui/views/OpenPushRequests/components/PushesTable.jsx index 2a3a7f33a..fad06e27e 100644 --- a/src/ui/views/OpenPushRequests/components/PushesTable.jsx +++ b/src/ui/views/OpenPushRequests/components/PushesTable.jsx @@ -23,6 +23,7 @@ export default function PushesTable(props) { const [filteredData, setFilteredData] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); const navigate = useNavigate(); const [, setAuth] = useState(true); const [currentPage, setCurrentPage] = useState(1); @@ -35,7 +36,7 @@ export default function PushesTable(props) { for (const k in props) { if (k) query[k] = props[k]; } - getPushes(setIsLoading, setData, setAuth, setIsError, query); + getPushes(setIsLoading, setData, setAuth, setIsError, setErrorMessage, query); }, [props]); useEffect(() => { @@ -69,7 +70,7 @@ export default function PushesTable(props) { const paginate = (pageNumber) => setCurrentPage(pageNumber); if (isLoading) return
Loading...
; - if (isError) return
Something went wrong ...
; + if (isError) return
{errorMessage}
; return (
diff --git a/src/ui/views/RepoList/Components/Repositories.jsx b/src/ui/views/RepoList/Components/Repositories.jsx index 228190903..3b64944f2 100644 --- a/src/ui/views/RepoList/Components/Repositories.jsx +++ b/src/ui/views/RepoList/Components/Repositories.jsx @@ -24,6 +24,7 @@ export default function Repositories(props) { const [, setAuth] = useState(true); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 5; const navigate = useNavigate(); @@ -44,6 +45,7 @@ export default function Repositories(props) { }, setAuth, setIsError, + setErrorMessage, query, ); }, [props]); @@ -99,7 +101,7 @@ export default function Repositories(props) { const paginatedData = filteredData.slice(startIdx, startIdx + itemsPerPage); if (isLoading) return
Loading...
; - if (isError) return
Something went wrong ...
; + if (isError) return
{errorMessage}
; const addrepoButton = user.admin ? ( diff --git a/src/ui/views/UserList/Components/UserList.jsx b/src/ui/views/UserList/Components/UserList.jsx index 36aef89e6..a3fdc9de4 100644 --- a/src/ui/views/UserList/Components/UserList.jsx +++ b/src/ui/views/UserList/Components/UserList.jsx @@ -25,6 +25,7 @@ export default function UserList(props) { const [, setAuth] = useState(true); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); const navigate = useNavigate(); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 5; @@ -39,11 +40,11 @@ export default function UserList(props) { if (!k) continue; query[k] = props[k]; } - getUsers(setIsLoading, setData, setAuth, setIsError, query); + getUsers(setIsLoading, setData, setAuth, setIsError, setErrorMessage, query); }, [props]); if (isLoading) return
Loading...
; - if (isError) return
Something went wrong...
; + if (isError) return
{errorMessage}
; const filteredUsers = data.filter( (user) =>