Skip to content

Commit 5f04f33

Browse files
authored
Merge branch 'main' into improve-auth-test-coverage
2 parents 4d742f6 + 1d30ab5 commit 5f04f33

File tree

18 files changed

+534
-111
lines changed

18 files changed

+534
-111
lines changed

cypress/e2e/login.cy.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ describe('Login page', () => {
3131
cy.url().should('include', '/dashboard/repo');
3232
})
3333

34+
it('should show an error snackbar on invalid login', () => {
35+
cy.get('[data-test="username"]').type('wronguser');
36+
cy.get('[data-test="password"]').type('wrongpass');
37+
cy.get('[data-test="login"]').click();
38+
39+
cy.get('.MuiSnackbarContent-message')
40+
.should('be.visible')
41+
.and('contain', 'You entered an invalid username or password...');
42+
});
43+
3444
describe('OIDC login button', () => {
3545
it('should exist', () => {
3646
cy.get('[data-test="oidc-login"]').should('exist');

package-lock.json

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@finos/git-proxy",
3-
"version": "1.17.2",
3+
"version": "1.18.0",
44
"description": "Deploy custom push protections and policies on top of Git.",
55
"scripts": {
66
"cli": "node ./packages/git-proxy-cli/index.js",
@@ -9,6 +9,7 @@
99
"server": "tsx index.ts",
1010
"start": "concurrently \"npm run server\" \"npm run client\"",
1111
"build": "npm run build-ui && npm run build-lib",
12+
"build-ts": "tsc",
1213
"build-ui": "vite build",
1314
"build-lib": "./scripts/build-for-publish.sh",
1415
"restore-lib": "./scripts/undo-build.sh",
@@ -66,7 +67,7 @@
6667
"moment": "^2.29.4",
6768
"mongodb": "^5.0.0",
6869
"nodemailer": "^6.6.1",
69-
"openid-client": "^6.3.1",
70+
"openid-client": "^6.4.2",
7071
"parse-diff": "^0.11.1",
7172
"passport": "^0.7.0",
7273
"passport-activedirectory": "^1.0.4",

proxy.config.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,14 @@
153153
"type": "jwt",
154154
"enabled": false,
155155
"jwtConfig": {
156+
"authorityURL": "",
156157
"clientID": "",
157-
"authorityURL": ""
158+
"expectedAudience": "",
159+
"roleMapping": {
160+
"admin": {
161+
"": ""
162+
}
163+
}
158164
}
159165
}
160166
],

src/config/index.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,9 @@ export const getDatabase = () => {
9494
* Get the list of enabled authentication methods
9595
*
9696
* At least one authentication method must be enabled.
97-
* @return {Array} List of enabled authentication methods
97+
* @return {Authentication[]} List of enabled authentication methods
9898
*/
99-
export const getAuthMethods = () => {
99+
export const getAuthMethods = (): Authentication[] => {
100100
if (_userSettings !== null && _userSettings.authentication) {
101101
_authentication = _userSettings.authentication;
102102
}
@@ -114,15 +114,19 @@ export const getAuthMethods = () => {
114114
* Get the list of enabled authentication methods for API endpoints
115115
*
116116
* If no API authentication methods are enabled, all endpoints are public.
117-
* @return {Array} List of enabled authentication methods
117+
* @return {Authentication[]} List of enabled authentication methods
118118
*/
119-
export const getAPIAuthMethods = () => {
119+
export const getAPIAuthMethods = (): Authentication[] => {
120120
if (_userSettings !== null && _userSettings.apiAuthentication) {
121121
_apiAuthentication = _userSettings.apiAuthentication;
122122
}
123123

124124
const enabledAuthMethods = _apiAuthentication.filter(auth => auth.enabled);
125125

126+
if (enabledAuthMethods.length === 0) {
127+
console.log("Warning: No authentication method enabled for API endpoints.");
128+
}
129+
126130
return enabledAuthMethods;
127131
};
128132

src/service/passport/index.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ const configure = async () => {
1616
passport.initialize();
1717

1818
const authMethods = config.getAuthMethods();
19-
console.log(`authMethods: ${JSON.stringify(authMethods)}`);
2019

2120
for (const auth of authMethods) {
2221
const strategy = authStrategies[auth.type.toLowerCase()];

src/service/passport/jwtAuthHandler.js

Lines changed: 14 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,19 @@
1-
const axios = require("axios");
2-
const jwt = require("jsonwebtoken");
3-
const jwkToPem = require("jwk-to-pem");
4-
const config = require('../../config');
1+
const { assignRoles, validateJwt } = require('./jwtUtils');
52

63
/**
7-
* Obtain the JSON Web Key Set (JWKS) from the OIDC authority.
8-
* @param {string} authorityUrl the OIDC authority URL. e.g. https://login.microsoftonline.com/{tenantId}
9-
* @return {Promise<object[]>} the JWKS keys
4+
* Middleware function to handle JWT authentication.
5+
* @param {*} overrideConfig optional configuration to override the default JWT configuration (e.g. for testing)
6+
* @return {Function} the middleware function
107
*/
11-
async function getJwks(authorityUrl) {
12-
try {
13-
const { data } = await axios.get(`${authorityUrl}/.well-known/openid-configuration`);
14-
const jwksUri = data.jwks_uri;
15-
16-
const { data: jwks } = await axios.get(jwksUri);
17-
return jwks.keys;
18-
} catch (error) {
19-
console.error("Error fetching JWKS:", error);
20-
throw new Error("Failed to fetch JWKS");
21-
}
22-
}
23-
24-
/**
25-
* Validate a JWT token using the OIDC configuration.
26-
* @param {*} token the JWT token
27-
* @param {*} authorityUrl the OIDC authority URL
28-
* @param {*} clientID the OIDC client ID
29-
* @param {*} expectedAudience the expected audience for the token
30-
* @return {Promise<object>} the verified payload or an error
31-
*/
32-
async function validateJwt(token, authorityUrl, clientID, expectedAudience) {
33-
try {
34-
const jwks = await getJwks(authorityUrl);
35-
36-
const decodedHeader = await jwt.decode(token, { complete: true });
37-
if (!decodedHeader || !decodedHeader.header || !decodedHeader.header.kid) {
38-
throw new Error("Invalid JWT: Missing key ID (kid)");
39-
}
40-
41-
const { kid } = decodedHeader.header;
42-
const jwk = jwks.find((key) => key.kid === kid);
43-
if (!jwk) {
44-
throw new Error("No matching key found in JWKS");
45-
}
46-
47-
const pubKey = jwkToPem(jwk);
48-
49-
const verifiedPayload = jwt.verify(token, pubKey, {
50-
algorithms: ["RS256"],
51-
issuer: authorityUrl,
52-
audience: expectedAudience,
53-
});
54-
55-
if (verifiedPayload.azp !== clientID) {
56-
throw new Error("JWT client ID does not match");
57-
}
58-
59-
return { verifiedPayload };
60-
} catch (error) {
61-
const errorMessage = `JWT validation failed: ${error.message}\n`;
62-
console.error(errorMessage);
63-
return { error: errorMessage };
64-
}
65-
}
66-
67-
const jwtAuthHandler = () => {
8+
const jwtAuthHandler = (overrideConfig = null) => {
689
return async (req, res, next) => {
69-
const apiAuthMethods = config.getAPIAuthMethods();
10+
const apiAuthMethods =
11+
overrideConfig
12+
? [{ type: "jwt", jwtConfig: overrideConfig }]
13+
: require('../../config').getAPIAuthMethods();
14+
7015
const jwtAuthMethod = apiAuthMethods.find((method) => method.type.toLowerCase() === "jwt");
71-
if (!jwtAuthMethod) {
16+
if (!overrideConfig && (!jwtAuthMethod || !jwtAuthMethod.enabled)) {
7217
return next();
7318
}
7419

@@ -81,7 +26,7 @@ const jwtAuthHandler = () => {
8126
return res.status(401).send("No token provided\n");
8227
}
8328

84-
const { clientID, authorityURL, expectedAudience } = jwtAuthMethod.jwtConfig;
29+
const { clientID, authorityURL, expectedAudience, roleMapping } = jwtAuthMethod.jwtConfig;
8530
const audience = expectedAudience || clientID;
8631

8732
if (!authorityURL) {
@@ -99,6 +44,8 @@ const jwtAuthHandler = () => {
9944
}
10045

10146
req.user = verifiedPayload;
47+
assignRoles(roleMapping, verifiedPayload, req.user);
48+
10249
return next();
10350
}
10451
}

src/service/passport/jwtUtils.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
const axios = require("axios");
2+
const jwt = require("jsonwebtoken");
3+
const jwkToPem = require("jwk-to-pem");
4+
5+
/**
6+
* Obtain the JSON Web Key Set (JWKS) from the OIDC authority.
7+
* @param {string} authorityUrl the OIDC authority URL. e.g. https://login.microsoftonline.com/{tenantId}
8+
* @return {Promise<object[]>} the JWKS keys
9+
*/
10+
async function getJwks(authorityUrl) {
11+
try {
12+
const { data } = await axios.get(`${authorityUrl}/.well-known/openid-configuration`);
13+
const jwksUri = data.jwks_uri;
14+
15+
const { data: jwks } = await axios.get(jwksUri);
16+
return jwks.keys;
17+
} catch (error) {
18+
console.error("Error fetching JWKS:", error);
19+
throw new Error("Failed to fetch JWKS");
20+
}
21+
}
22+
23+
/**
24+
* Validate a JWT token using the OIDC configuration.
25+
* @param {*} token the JWT token
26+
* @param {*} authorityUrl the OIDC authority URL
27+
* @param {*} clientID the OIDC client ID
28+
* @param {*} expectedAudience the expected audience for the token
29+
* @param {*} getJwksInject the getJwks function to use (for dependency injection). Defaults to the built-in getJwks function.
30+
* @return {Promise<object>} the verified payload or an error
31+
*/
32+
async function validateJwt(token, authorityUrl, clientID, expectedAudience, getJwksInject = getJwks) {
33+
try {
34+
const jwks = await getJwksInject(authorityUrl);
35+
36+
const decodedHeader = await jwt.decode(token, { complete: true });
37+
if (!decodedHeader || !decodedHeader.header || !decodedHeader.header.kid) {
38+
throw new Error("Invalid JWT: Missing key ID (kid)");
39+
}
40+
41+
const { kid } = decodedHeader.header;
42+
const jwk = jwks.find((key) => key.kid === kid);
43+
if (!jwk) {
44+
throw new Error("No matching key found in JWKS");
45+
}
46+
47+
const pubKey = jwkToPem(jwk);
48+
49+
const verifiedPayload = jwt.verify(token, pubKey, {
50+
algorithms: ["RS256"],
51+
issuer: authorityUrl,
52+
audience: expectedAudience,
53+
});
54+
55+
if (verifiedPayload.azp !== clientID) {
56+
throw new Error("JWT client ID does not match");
57+
}
58+
59+
return { verifiedPayload };
60+
} catch (error) {
61+
const errorMessage = `JWT validation failed: ${error.message}\n`;
62+
console.error(errorMessage);
63+
return { error: errorMessage };
64+
}
65+
}
66+
67+
/**
68+
* Assign roles to the user based on the role mappings provided in the jwtConfig.
69+
*
70+
* If no role mapping is provided, the user will not have any roles assigned (i.e. user.admin = false).
71+
* @param {*} roleMapping the role mapping configuration
72+
* @param {*} payload the JWT payload
73+
* @param {*} user the req.user object to assign roles to
74+
*/
75+
function assignRoles(roleMapping, payload, user) {
76+
if (roleMapping) {
77+
for (const role of Object.keys(roleMapping)) {
78+
const claimValuePair = roleMapping[role];
79+
const claim = Object.keys(claimValuePair)[0];
80+
const value = claimValuePair[claim];
81+
82+
if (payload[claim] && payload[claim] === value) {
83+
user[role] = true;
84+
}
85+
}
86+
}
87+
}
88+
89+
module.exports = {
90+
getJwks,
91+
validateJwt,
92+
assignRoles,
93+
};

src/service/passport/oidc.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,16 @@ const configure = async (passport) => {
1717
}
1818

1919
const server = new URL(issuer);
20+
let config;
2021

2122
try {
22-
const config = await discovery(server, clientID, clientSecret);
23+
config = await discovery(server, clientID, clientSecret);
24+
} catch (error) {
25+
console.error('Error during OIDC discovery:', error);
26+
throw new Error('OIDC setup error (discovery): ' + error.message);
27+
}
2328

29+
try {
2430
const strategy = new Strategy({ callbackURL, config, scope }, async (tokenSet, done) => {
2531
// Validate token sub for added security
2632
const idTokenClaims = tokenSet.claims();
@@ -56,8 +62,8 @@ const configure = async (passport) => {
5662

5763
return passport;
5864
} catch (error) {
59-
console.error('OIDC configuration failed:', error);
60-
throw error;
65+
console.error('Error during OIDC passport setup:', error);
66+
throw new Error('OIDC setup error (strategy): ' + error.message);
6167
}
6268
};
6369

0 commit comments

Comments
 (0)