Skip to content

feat(auth): add role mapping for JWT auth claims #977

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Jun 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
cb3d110
fix(auth): remove jwt data from config and disable by default
jescalada Apr 13, 2025
fdaeb6b
feat(auth): add role mapping and assignment on jwt claims
jescalada Apr 13, 2025
5e1440e
test(auth): add test for getJwks helper
jescalada Apr 13, 2025
24cba4d
chore(auth): move jwt util functions into own file for testing
jescalada Apr 13, 2025
dad5beb
test(auth): add test for validateJwt helper function
jescalada Apr 13, 2025
407cb85
test(auth): add test for assignRoles helper function
jescalada Apr 13, 2025
420be8d
test(auth): add tests for jwtAuthHandler
jescalada Apr 13, 2025
5bdcd69
chore: add missing jwtConfig parameter (optional)
jescalada Apr 16, 2025
8decdea
Merge remote-tracking branch 'origin/main' into jwt-claims-role-mapping
jescalada Apr 16, 2025
aee4b4f
fix: fix failing tests
jescalada Apr 16, 2025
583616a
fix: remove unneeded oidc config params and values
jescalada Apr 16, 2025
f31bc6d
fix: fix linter and test issues
jescalada Apr 16, 2025
412b209
fix: e2e test fail due to route refactor (admin -> dashboard)
jescalada Apr 16, 2025
336e51d
fix: e2e test fail (login required)
jescalada Apr 16, 2025
27cfb07
chore: update package.json scripts
jescalada Jun 13, 2025
e77aec2
Merge remote-tracking branch 'origin/main' into jwt-claims-role-mapping
jescalada Jun 13, 2025
b808b61
chore: improve oidc error handling
jescalada Jun 13, 2025
dccb6c4
chore: improve /repo error handling
jescalada Jun 13, 2025
7b8df5f
test: add extra unit test for invalid login
jescalada Jun 15, 2025
3d420a2
docs: add authentication doc page
jescalada Jun 15, 2025
31cba39
docs: fix broken links
jescalada Jun 15, 2025
25f0065
fix: make error handling more descriptive and catch JWT config error
jescalada Jun 15, 2025
65617cd
Merge branch 'main' into jwt-claims-role-mapping
JamieSlome Jun 16, 2025
699f31f
Merge branch 'main' into jwt-claims-role-mapping
JamieSlome Jun 17, 2025
9f554b7
Merge branch 'main' into jwt-claims-role-mapping
jescalada Jun 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cypress/e2e/login.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion proxy.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,14 @@
"type": "jwt",
"enabled": false,
"jwtConfig": {
"authorityURL": "",
"clientID": "",
"authorityURL": ""
"expectedAudience": "",
"roleMapping": {
"admin": {
"": ""
}
}
}
}
],
Expand Down
12 changes: 8 additions & 4 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
};

Expand Down
1 change: 0 additions & 1 deletion src/service/passport/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()];
Expand Down
81 changes: 14 additions & 67 deletions src/service/passport/jwtAuthHandler.js
Original file line number Diff line number Diff line change
@@ -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<object[]>} 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<object>} 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();
}

Expand All @@ -81,7 +26,7 @@
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) {
Expand All @@ -99,6 +44,8 @@
}

req.user = verifiedPayload;
assignRoles(roleMapping, verifiedPayload, req.user);

Check warning on line 47 in src/service/passport/jwtAuthHandler.js

View check run for this annotation

Codecov / codecov/patch

src/service/passport/jwtAuthHandler.js#L47

Added line #L47 was not covered by tests

return next();
}
}
Expand Down
93 changes: 93 additions & 0 deletions src/service/passport/jwtUtils.js
Original file line number Diff line number Diff line change
@@ -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<object[]>} 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<object>} 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");

Check warning on line 44 in src/service/passport/jwtUtils.js

View check run for this annotation

Codecov / codecov/patch

src/service/passport/jwtUtils.js#L44

Added line #L44 was not covered by tests
}

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");

Check warning on line 56 in src/service/passport/jwtUtils.js

View check run for this annotation

Codecov / codecov/patch

src/service/passport/jwtUtils.js#L56

Added line #L56 was not covered by tests
}

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,
};
12 changes: 9 additions & 3 deletions src/service/passport/oidc.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@
}

const server = new URL(issuer);
let config;

try {
const config = await discovery(server, clientID, clientSecret);
config = await discovery(server, clientID, clientSecret);

Check warning on line 23 in src/service/passport/oidc.js

View check run for this annotation

Codecov / codecov/patch

src/service/passport/oidc.js#L23

Added line #L23 was not covered by tests
} catch (error) {
console.error('Error during OIDC discovery:', error);
throw new Error('OIDC setup error (discovery): ' + error.message);

Check warning on line 26 in src/service/passport/oidc.js

View check run for this annotation

Codecov / codecov/patch

src/service/passport/oidc.js#L25-L26

Added lines #L25 - L26 were not covered by tests
}

try {

Check warning on line 29 in src/service/passport/oidc.js

View check run for this annotation

Codecov / codecov/patch

src/service/passport/oidc.js#L29

Added line #L29 was not covered by tests
const strategy = new Strategy({ callbackURL, config, scope }, async (tokenSet, done) => {
// Validate token sub for added security
const idTokenClaims = tokenSet.claims();
Expand Down Expand Up @@ -56,8 +62,8 @@

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);

Check warning on line 66 in src/service/passport/oidc.js

View check run for this annotation

Codecov / codecov/patch

src/service/passport/oidc.js#L65-L66

Added lines #L65 - L66 were not covered by tests
}
};

Expand Down
9 changes: 8 additions & 1 deletion src/service/routes/repo.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
});

router.delete('/:name/delete', async (req, res) => {
if (req.user.admin) {
if (req.user && req.user.admin) {

Check warning on line 113 in src/service/routes/repo.js

View check run for this annotation

Codecov / codecov/patch

src/service/routes/repo.js#L113

Added line #L113 was not covered by tests
const repoName = req.params.name;

await db.deleteRepo(repoName);
Expand All @@ -124,6 +124,13 @@

router.post('/', async (req, res) => {
if (req.user && req.user.admin) {
if (!req.body.name) {
res.status(400).send({

Check warning on line 128 in src/service/routes/repo.js

View check run for this annotation

Codecov / codecov/patch

src/service/routes/repo.js#L128

Added line #L128 was not covered by tests
message: 'Repository name is required',
});
return;

Check warning on line 131 in src/service/routes/repo.js

View check run for this annotation

Codecov / codecov/patch

src/service/routes/repo.js#L131

Added line #L131 was not covered by tests
}

const repo = await db.getRepo(req.body.name);
if (repo) {
res.status(409).send({
Expand Down
8 changes: 5 additions & 3 deletions src/ui/services/git-push.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const getPushes = async (
setData,
setAuth,
setIsError,
setErrorMessage,
query = {
blocked: true,
canceled: false,
Expand All @@ -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);
});
};
Expand Down
Loading
Loading