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'); diff --git a/package.json b/package.json index 40885046d..c422c7ee5 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "server": "tsx index.ts", "start": "concurrently \"npm run server\" \"npm run client\"", "build": "npm run build-ui && npm run build-lib", + "build-ts": "tsc", "build-ui": "vite build", "build-lib": "./scripts/build-for-publish.sh", "restore-lib": "./scripts/undo-build.sh", @@ -66,7 +67,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/proxy.config.json b/proxy.config.json index 99d000731..6b2970c30 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -153,8 +153,14 @@ "type": "jwt", "enabled": false, "jwtConfig": { + "authorityURL": "", "clientID": "", - "authorityURL": "" + "expectedAudience": "", + "roleMapping": { + "admin": { + "": "" + } + } } } ], diff --git a/src/config/index.ts b/src/config/index.ts index db8f19f34..a3ea42136 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -94,9 +94,9 @@ export const getDatabase = () => { * Get the list of enabled authentication methods * * At least one authentication method must be enabled. - * @return {Array} List of enabled authentication methods + * @return {Authentication[]} List of enabled authentication methods */ -export const getAuthMethods = () => { +export const getAuthMethods = (): Authentication[] => { if (_userSettings !== null && _userSettings.authentication) { _authentication = _userSettings.authentication; } @@ -114,15 +114,19 @@ export const getAuthMethods = () => { * Get the list of enabled authentication methods for API endpoints * * If no API authentication methods are enabled, all endpoints are public. - * @return {Array} List of enabled authentication methods + * @return {Authentication[]} List of enabled authentication methods */ -export const getAPIAuthMethods = () => { +export const getAPIAuthMethods = (): Authentication[] => { if (_userSettings !== null && _userSettings.apiAuthentication) { _apiAuthentication = _userSettings.apiAuthentication; } const enabledAuthMethods = _apiAuthentication.filter(auth => auth.enabled); + if (enabledAuthMethods.length === 0) { + console.log("Warning: No authentication method enabled for API endpoints."); + } + return enabledAuthMethods; }; 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/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js index da9e3bc47..32c81304d 100644 --- a/src/service/passport/jwtAuthHandler.js +++ b/src/service/passport/jwtAuthHandler.js @@ -1,74 +1,19 @@ -const axios = require("axios"); -const jwt = require("jsonwebtoken"); -const jwkToPem = require("jwk-to-pem"); -const config = require('../../config'); +const { assignRoles, validateJwt } = require('./jwtUtils'); /** - * 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 + * Middleware function to handle JWT authentication. + * @param {*} overrideConfig optional configuration to override the default JWT configuration (e.g. for testing) + * @return {Function} the middleware function */ -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 }; - } -} - -const jwtAuthHandler = () => { +const jwtAuthHandler = (overrideConfig = null) => { return async (req, res, next) => { - const apiAuthMethods = config.getAPIAuthMethods(); + const apiAuthMethods = + overrideConfig + ? [{ type: "jwt", jwtConfig: overrideConfig }] + : require('../../config').getAPIAuthMethods(); + const jwtAuthMethod = apiAuthMethods.find((method) => method.type.toLowerCase() === "jwt"); - if (!jwtAuthMethod) { + if (!overrideConfig && (!jwtAuthMethod || !jwtAuthMethod.enabled)) { return next(); } @@ -81,7 +26,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) { @@ -99,6 +44,8 @@ const jwtAuthHandler = () => { } req.user = verifiedPayload; + assignRoles(roleMapping, verifiedPayload, req.user); + return next(); } } diff --git a/src/service/passport/jwtUtils.js b/src/service/passport/jwtUtils.js new file mode 100644 index 000000000..45bda4cc9 --- /dev/null +++ b/src/service/passport/jwtUtils.js @@ -0,0 +1,93 @@ +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 + * @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, getJwksInject = getJwks) { + try { + const jwks = await getJwksInject(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, +}; 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); } }; 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({ 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) => diff --git a/test/testJwtAuthHandler.test.js b/test/testJwtAuthHandler.test.js new file mode 100644 index 000000000..af2bb1bb2 --- /dev/null +++ b/test/testJwtAuthHandler.test.js @@ -0,0 +1,193 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const axios = require('axios'); +const jwt = require('jsonwebtoken'); +const { jwkToBuffer } = require('jwk-to-pem'); + +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 () => { + 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(); + }); +}); + +describe('validateJwt', () => { + let decodeStub; + let verifyStub; + let pemStub; + let 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'); + }); +}); + +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; + }); +}); + +describe('jwtAuthHandler', () => { + let req; + let res; + let next; + let jwtConfig; + let 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(jwtConfig)(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; + }); +}); diff --git a/website/docs/configuration/authentication.mdx b/website/docs/configuration/authentication.mdx new file mode 100644 index 000000000..626c4f746 --- /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](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 + +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](https://github.com/finos/git-proxy/blob/main/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](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": [ + { + "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](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'); +const activeDirectory = require('./activeDirectory'); +const oidc = require('./oidc'); + +const authStrategies = { + local: local, + activedirectory: activeDirectory, + openidconnect: oidc, +}; +``` + +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? + +If you have any questions, feel free to [open a discussion](https://github.com/finos/git-proxy/discussions). \ No newline at end of file