From e0c61ca87e313f60f8fea3c1060d795b8ec423fb Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 24 Jun 2025 20:32:20 +0900 Subject: [PATCH 01/17] refactor(ts): OIDC passport strategy --- src/service/passport/{oidc.js => oidc.ts} | 74 ++++++++++++----------- 1 file changed, 38 insertions(+), 36 deletions(-) rename src/service/passport/{oidc.js => oidc.ts} (57%) diff --git a/src/service/passport/oidc.js b/src/service/passport/oidc.ts similarity index 57% rename from src/service/passport/oidc.js rename to src/service/passport/oidc.ts index 7e2aa5ee0..2645e4cef 100644 --- a/src/service/passport/oidc.js +++ b/src/service/passport/oidc.ts @@ -1,42 +1,47 @@ -const db = require('../../db'); +import * as db from '../../db'; +import { PassportStatic } from 'passport'; +import { getAuthMethods } from '../../config/index.ts'; -const type = 'openidconnect'; +export const type = 'openidconnect'; -const configure = async (passport) => { - // Temp fix for ERR_REQUIRE_ESM, will be changed when we refactor to ESM +export const configure = async (passport: PassportStatic): Promise => { + // Use dynamic imports to avoid ESM/CommonJS issues const { discovery, fetchUserInfo } = await import('openid-client'); const { Strategy } = await import('openid-client/passport'); - const authMethods = require('../../config').getAuthMethods(); + + const authMethods = getAuthMethods(); const oidcConfig = authMethods.find( (method) => method.type.toLowerCase() === 'openidconnect', )?.oidcConfig; - const { issuer, clientID, clientSecret, callbackURL, scope } = oidcConfig; if (!oidcConfig || !oidcConfig.issuer) { throw new Error('Missing OIDC issuer in configuration'); } + const { issuer, clientID, clientSecret, callbackURL, scope } = oidcConfig; + const server = new URL(issuer); let config; try { config = await discovery(server, clientID, clientSecret); - } catch (error) { + } catch (error: any) { 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(); - const expectedSub = idTokenClaims.sub; - const userInfo = await fetchUserInfo(config, tokenSet.access_token, expectedSub); - handleUserAuthentication(userInfo, done); - }); + const strategy = new Strategy( + { callbackURL, config, scope }, + async (tokenSet: any, done: Function) => { + const idTokenClaims = tokenSet.claims(); + const expectedSub = idTokenClaims.sub; + const userInfo = await fetchUserInfo(config, tokenSet.access_token, expectedSub); + handleUserAuthentication(userInfo, done); + } + ); - // currentUrl must be overridden to match the callback URL - strategy.currentUrl = function (request) { + strategy.currentUrl = function (request: any) { const callbackUrl = new URL(callbackURL); const currentUrl = Strategy.prototype.currentUrl.call(this, request); currentUrl.host = callbackUrl.host; @@ -44,24 +49,23 @@ const configure = async (passport) => { return currentUrl; }; - // Prevent default strategy name from being overridden with the server host passport.use(type, strategy); - passport.serializeUser((user, done) => { + passport.serializeUser((user: any, done) => { done(null, user.oidcId || user.username); }); - passport.deserializeUser(async (id, done) => { + passport.deserializeUser(async (id: string, done) => { try { const user = await db.findUserByOIDC(id); done(null, user); } catch (err) { - done(err); + done(err as Error); } }); return passport; - } catch (error) { + } catch (error: any) { console.error('Error during OIDC passport setup:', error); throw new Error('OIDC setup error (strategy): ' + error.message); } @@ -69,11 +73,8 @@ const configure = async (passport) => { /** * Handles user authentication with OIDC. - * @param {Object} userInfo the OIDC user info object - * @param {Function} done the callback function - * @return {Promise} a promise with the authenticated user or an error */ -const handleUserAuthentication = async (userInfo, done) => { +const handleUserAuthentication = async (userInfo: any, done: Function): Promise => { console.log('handleUserAuthentication called'); try { const user = await db.findUserByOIDC(userInfo.sub); @@ -88,7 +89,14 @@ const handleUserAuthentication = async (userInfo, done) => { oidcId: userInfo.sub, }; - await db.createUser(newUser.username, null, newUser.email, 'Edit me', false, newUser.oidcId); + await db.createUser( + newUser.username, + '', + newUser.email, + 'Edit me', + false, + newUser.oidcId, + ); return done(null, newUser); } @@ -100,26 +108,20 @@ const handleUserAuthentication = async (userInfo, done) => { /** * Extracts email from OIDC profile. - * This function is necessary because OIDC providers have different ways of storing emails. - * @param {object} profile the profile object from OIDC provider - * @return {string | null} the email address + * Different providers use different fields to store the email. */ -const safelyExtractEmail = (profile) => { +const safelyExtractEmail = (profile: any): string | null => { return ( profile.email || (profile.emails && profile.emails.length > 0 ? profile.emails[0].value : null) ); }; /** - * Generates a username from email address. + * Generates a username from an email address. * This helps differentiate users within the specific OIDC provider. * Note: This is incompatible with multiple providers. Ideally, users are identified by * OIDC ID (requires refactoring the database). - * @param {string} email the email address - * @return {string} the username */ -const getUsername = (email) => { +const getUsername = (email: string): string => { return email ? email.split('@')[0] : ''; }; - -module.exports = { configure, type }; From 75c4b255ebf46b1414f33ba7cd76f1ec4b88bf33 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 24 Jun 2025 20:33:18 +0900 Subject: [PATCH 02/17] refactor(ts): local passport strategy --- src/service/passport/{local.js => local.ts} | 25 ++++++++++----------- 1 file changed, 12 insertions(+), 13 deletions(-) rename src/service/passport/{local.js => local.ts} (57%) diff --git a/src/service/passport/local.js b/src/service/passport/local.ts similarity index 57% rename from src/service/passport/local.js rename to src/service/passport/local.ts index 579d47234..6f1460a32 100644 --- a/src/service/passport/local.js +++ b/src/service/passport/local.ts @@ -1,12 +1,13 @@ -const bcrypt = require("bcryptjs"); -const LocalStrategy = require("passport-local").Strategy; -const db = require("../../db"); +import bcrypt from "bcryptjs"; +import { Strategy as LocalStrategy } from "passport-local"; +import type { PassportStatic } from "passport"; +import * as db from "../../db"; -const type = "local"; +export const type = "local"; -const configure = async (passport) => { +export const configure = async (passport: PassportStatic): Promise => { passport.use( - new LocalStrategy(async (username, password, done) => { + new LocalStrategy(async (username: string, password: string, done) => { try { const user = await db.findUser(username); if (!user) { @@ -20,21 +21,21 @@ const configure = async (passport) => { return done(null, user); } catch (err) { - return done(err); + return done(err as Error); } }) ); - passport.serializeUser((user, done) => { + passport.serializeUser((user: any, done) => { done(null, user.username); }); - passport.deserializeUser(async (username, done) => { + passport.deserializeUser(async (username: string, done) => { try { const user = await db.findUser(username); done(null, user); } catch (err) { - done(err, null); + done(err as Error, null); } }); @@ -44,11 +45,9 @@ const configure = async (passport) => { /** * Create the default admin user if it doesn't exist */ -const createDefaultAdmin = async () => { +export const createDefaultAdmin = async (): Promise => { const admin = await db.findUser("admin"); if (!admin) { await db.createUser("admin", "admin", "admin@place.com", "none", true); } }; - -module.exports = { configure, createDefaultAdmin, type }; From ba0dad1c9dc1f9eb3a07a23b7976688af7b05d0c Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Tue, 24 Jun 2025 20:48:23 +0900 Subject: [PATCH 03/17] refactor(ts): AD passport strategy and test fixes --- package-lock.json | 58 +++++++++++++++---- package.json | 1 + src/config/types.ts | 33 ++++++++++- ...{activeDirectory.js => activeDirectory.ts} | 53 ++++++++++------- src/types/passport-activedirectory.d.ts | 7 +++ test/chain.test.js | 2 +- tsconfig.json | 5 +- 7 files changed, 121 insertions(+), 38 deletions(-) rename src/service/passport/{activeDirectory.js => activeDirectory.ts} (53%) create mode 100644 src/types/passport-activedirectory.d.ts diff --git a/package-lock.json b/package-lock.json index 57efef641..ff96f49b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,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", @@ -71,6 +71,7 @@ "@types/lodash": "^4.17.15", "@types/mocha": "^10.0.10", "@types/node": "^22.13.5", + "@types/passport-local": "^1.0.38", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", @@ -3765,6 +3766,39 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-local": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", + "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", @@ -9167,9 +9201,9 @@ } }, "node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz", + "integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -10800,9 +10834,9 @@ } }, "node_modules/oauth4webapi": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.3.0.tgz", - "integrity": "sha512-ZlozhPlFfobzh3hB72gnBFLjXpugl/dljz1fJSRdqaV2r3D5dmi5lg2QWI0LmUYuazmE+b5exsloEv6toUtw9g==", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.5.3.tgz", + "integrity": "sha512-2bnHosmBLAQpXNBLOvaJMyMkr4Yya5ohE5Q9jqyxiN+aa7GFCzvDN1RRRMrp0NkfqRR2MTaQNkcSUCCjILD9oQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -10955,13 +10989,13 @@ } }, "node_modules/openid-client": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.3.1.tgz", - "integrity": "sha512-l+uRCCM+KaGKQmCWjrjlFHXa1husuc72OPCCyGB7VjHeEMVldfwsn4Pfb/5Xk51FRqbRakMbwfIUPiAMQDHaSw==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.6.1.tgz", + "integrity": "sha512-GmqoICGMI3IyFFjhvXxad8of4QWk2D0tm4vdJkldGm9nw7J3p1f7LPLWgGeFuKuw8HjDVe8Dd8QLGBe0NFvSSg==", "license": "MIT", "dependencies": { - "jose": "^5.10.0", - "oauth4webapi": "^3.3.0" + "jose": "^6.0.11", + "oauth4webapi": "^3.5.3" }, "funding": { "url": "https://github.com/sponsors/panva" diff --git a/package.json b/package.json index c422c7ee5..d5d954d41 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "@types/lodash": "^4.17.15", "@types/mocha": "^10.0.10", "@types/node": "^22.13.5", + "@types/passport-local": "^1.0.38", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", diff --git a/src/config/types.ts b/src/config/types.ts index 291de4081..02b32c073 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -48,7 +48,38 @@ export interface Database { export interface Authentication { type: string; enabled: boolean; - options?: Record; + oidcConfig?: OidcConfig; + adConfig?: AdConfig; + jwtConfig?: JwtConfig; + + // Deprecated fields for backwards compatibility + // TODO: remove in future release and keep the ones in adConfig + userGroup?: string; + adminGroup?: string; + domain?: string; +} + +export interface OidcConfig { + issuer: string; + clientID: string; + clientSecret: string; + callbackURL: string; + scope: string; +} + +export interface AdConfig { + url: string; + baseDN: string; + searchBase: string; + userGroup?: string; + adminGroup?: string; + domain?: string; +} + +export interface JwtConfig { + clientID: string; + authorityURL: string; + roleMapping: Record; } export interface TempPasswordConfig { diff --git a/src/service/passport/activeDirectory.js b/src/service/passport/activeDirectory.ts similarity index 53% rename from src/service/passport/activeDirectory.js rename to src/service/passport/activeDirectory.ts index 8d8752371..ebb4d0e95 100644 --- a/src/service/passport/activeDirectory.js +++ b/src/service/passport/activeDirectory.ts @@ -1,18 +1,32 @@ -const ActiveDirectoryStrategy = require('passport-activedirectory'); -const ldaphelper = require('./ldaphelper'); +import ActiveDirectoryStrategy from 'passport-activedirectory'; +import { PassportStatic } from 'passport'; +import * as ldaphelper from './ldaphelper'; +import * as db from '../../db'; +import { getAuthMethods } from '../../config'; -const type = 'activedirectory'; +export const type = 'activedirectory'; -const configure = (passport) => { - const db = require('../../db'); - - // We can refactor this by normalizing auth strategy config and pass it directly into the configure() function, - // ideally when we convert this to TS. - const authMethods = require('../../config').getAuthMethods(); +export const configure = (passport: PassportStatic): PassportStatic => { + const authMethods = getAuthMethods(); const config = authMethods.find((method) => method.type.toLowerCase() === type); + + if (!config) { + throw new Error('AD authentication method not enabled'); + } + const adConfig = config.adConfig; - const { userGroup, adminGroup, domain } = config; + if (!adConfig) { + throw new Error('Invalid Active Directory configuration'); + } + + const userGroup = adConfig.userGroup || config.userGroup; + const adminGroup = adConfig.adminGroup || config.adminGroup; + const domain = adConfig.domain || config.domain; + + if (!userGroup || !adminGroup || !domain) { + throw new Error('Invalid Active Directory configuration'); + } console.log(`AD User Group: ${userGroup}, AD Admin Group: ${adminGroup}`); @@ -24,24 +38,21 @@ const configure = (passport) => { integrated: false, ldap: adConfig, }, - async function (req, profile, ad, done) { + async function (req: any, profile: any, ad: any, done: (err: any, user: any) => void) { try { profile.username = profile._json.sAMAccountName?.toLowerCase(); profile.email = profile._json.mail; profile.id = profile.username; req.user = profile; - // First check to see if the user is in the usergroups const isUser = await ldaphelper.isUserInAdGroup(profile.username, domain, userGroup); if (!isUser) { - const message = `User it not a member of ${userGroup}`; + const message = `User is not a member of ${userGroup}`; return done(message, null); } - // Now check if the user is an admin const isAdmin = await ldaphelper.isUserInAdGroup(profile.username, domain, adminGroup); - profile.admin = isAdmin; const user = { @@ -55,23 +66,21 @@ const configure = (passport) => { await db.updateUser(user); return done(null, user); - } catch (err) { + } catch (err: any) { console.log(`Error authenticating AD user: ${err.message}`); return done(err, null); } - }, - ), + } + ) ); - passport.serializeUser(function (user, done) { + passport.serializeUser(function (user: any, done: (err: any, user: any) => void) { done(null, user); }); - passport.deserializeUser(function (user, done) { + passport.deserializeUser(function (user: any, done: (err: any, user: any) => void) { done(null, user); }); return passport; }; - -module.exports = { configure, type }; diff --git a/src/types/passport-activedirectory.d.ts b/src/types/passport-activedirectory.d.ts new file mode 100644 index 000000000..1578409ae --- /dev/null +++ b/src/types/passport-activedirectory.d.ts @@ -0,0 +1,7 @@ +declare module 'passport-activedirectory' { + import { Strategy as PassportStrategy } from 'passport'; + class Strategy extends PassportStrategy { + constructor(options: any, verify: (...args: any[]) => void); + } + export = Strategy; +} diff --git a/test/chain.test.js b/test/chain.test.js index 1fc749248..7dbcf4fa7 100644 --- a/test/chain.test.js +++ b/test/chain.test.js @@ -74,7 +74,7 @@ describe('proxy chain', function () { mockPushProcessors = initMockPushProcessors(sandboxSinon); // Re-import the processors module after clearing the cache - processors = await import('../src/proxy/processors'); + processors = require('../src/proxy/processors/index.ts'); // Mock the processors module sandboxSinon.stub(processors, 'pre').value(mockPreProcessors); diff --git a/tsconfig.json b/tsconfig.json index a389ca8c7..baaaf0b0e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,14 +5,15 @@ "allowJs": true, "checkJs": false, "jsx": "react-jsx", - "moduleResolution": "Node", + "moduleResolution": "nodenext", "strict": true, "noEmit": true, "declaration": true, "skipLibCheck": true, "isolatedModules": true, - "module": "CommonJS", + "module": "NodeNext", "esModuleInterop": true, + "allowImportingTsExtensions": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true }, From 60b51c8111d85fe67306b25ac3133dc726d17350 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Wed, 25 Jun 2025 15:32:10 +0900 Subject: [PATCH 04/17] refactor(ts): passport helper files --- src/service/passport/jwtUtils.js | 93 ---------------------------- src/service/passport/jwtUtils.ts | 99 ++++++++++++++++++++++++++++++ src/service/passport/ldaphelper.js | 27 -------- src/service/passport/ldaphelper.ts | 31 ++++++++++ 4 files changed, 130 insertions(+), 120 deletions(-) delete mode 100644 src/service/passport/jwtUtils.js create mode 100644 src/service/passport/jwtUtils.ts delete mode 100644 src/service/passport/ldaphelper.js create mode 100644 src/service/passport/ldaphelper.ts diff --git a/src/service/passport/jwtUtils.js b/src/service/passport/jwtUtils.js deleted file mode 100644 index 45bda4cc9..000000000 --- a/src/service/passport/jwtUtils.js +++ /dev/null @@ -1,93 +0,0 @@ -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/jwtUtils.ts b/src/service/passport/jwtUtils.ts new file mode 100644 index 000000000..06cb2dac4 --- /dev/null +++ b/src/service/passport/jwtUtils.ts @@ -0,0 +1,99 @@ +import axios from 'axios'; +import jwt, { JwtPayload } from 'jsonwebtoken'; +import jwkToPem from 'jwk-to-pem'; + +import { JwkKey, JwksResponse, JwtValidationResult, RoleMapping } from './types'; + +/** + * 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 + */ +export async function getJwks(authorityUrl: string): Promise { + try { + const { data } = await axios.get(`${authorityUrl}/.well-known/openid-configuration`); + const jwksUri: string = data.jwks_uri; + + const { data: jwks }: { data: JwksResponse } = 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 {string} token the JWT token + * @param {string} authorityUrl the OIDC authority URL + * @param {string} clientID the OIDC client ID + * @param {string} expectedAudience the expected audience for the token + * @param {Function} getJwksInject the getJwks function to use (for dependency injection). Defaults to the built-in getJwks function. + * @return {Promise} the verified payload or an error + */ +export async function validateJwt( + token: string, + authorityUrl: string, + expectedAudience: string, + clientID: string, + getJwksInject: (authorityUrl: string) => Promise = getJwks +): Promise { + try { + const jwks = await getJwksInject(authorityUrl); + + const decoded = jwt.decode(token, { complete: true }); + if (!decoded || typeof decoded !== 'object' || !decoded.header?.kid) { + throw new Error('Invalid JWT: Missing key ID (kid)'); + } + + const { kid } = decoded.header; + const jwk = jwks.find((key) => key.kid === kid); + if (!jwk) { + throw new Error('No matching key found in JWKS'); + } + + const pubKey = jwkToPem(jwk as any); + + const verifiedPayload = jwt.verify(token, pubKey, { + algorithms: ['RS256'], + issuer: authorityUrl, + audience: expectedAudience, + }) as JwtPayload; + + if (verifiedPayload.azp && verifiedPayload.azp !== clientID) { + throw new Error('JWT client ID does not match'); + } + + return { verifiedPayload, error: null }; + } catch (error: any) { + const errorMessage = `JWT validation failed: ${error.message}\n`; + console.error(errorMessage); + return { error: errorMessage, verifiedPayload: null }; + } +} + +/** + * 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} roleMapping the role mapping configuration + * @param {JwtPayload} payload the JWT payload + * @param {Record} user the req.user object to assign roles to + */ +export function assignRoles( + roleMapping: RoleMapping | undefined, + payload: JwtPayload, + user: Record +): void { + if (!roleMapping) return; + + for (const role of Object.keys(roleMapping)) { + const claimMap = roleMapping[role]; + const claim = Object.keys(claimMap)[0]; + const value = claimMap[claim]; + + if (payload[claim] && payload[claim] === value) { + user[role] = true; + } + } +} diff --git a/src/service/passport/ldaphelper.js b/src/service/passport/ldaphelper.js deleted file mode 100644 index 886b2c4a4..000000000 --- a/src/service/passport/ldaphelper.js +++ /dev/null @@ -1,27 +0,0 @@ -const axios = require('axios'); -const thirdpartyApiConfig = require('../../config').getAPIs(); -const client = axios.create({ - responseType: 'json', - headers: { - 'content-type': 'application/json', - }, -}); - -const isUserInAdGroup = (id, domain, name) => { - const url = String(thirdpartyApiConfig.ls.userInADGroup) - .replace('', domain) - .replace('', name) - .replace('', id); - - console.log(`checking if user is in group ${url}`); - return client - .get(url) - .then((res) => res.data) - .catch(() => { - return false; - }); -}; - -module.exports = { - isUserInAdGroup, -}; diff --git a/src/service/passport/ldaphelper.ts b/src/service/passport/ldaphelper.ts new file mode 100644 index 000000000..06decd309 --- /dev/null +++ b/src/service/passport/ldaphelper.ts @@ -0,0 +1,31 @@ +import axios, { AxiosInstance } from 'axios'; +import { getAPIs } from '../../config'; + +const thirdpartyApiConfig: Record = getAPIs(); + +const client: AxiosInstance = axios.create({ + responseType: 'json', + headers: { + 'content-type': 'application/json', + }, +}); + +export const isUserInAdGroup = async ( + id: string, + domain: string, + name: string +): Promise => { + const url = String(thirdpartyApiConfig.ls.userInADGroup) + .replace('', domain) + .replace('', name) + .replace('', id); + + console.log(`checking if user is in group ${url}`); + + try { + const res = await client.get(url); + return res.data; + } catch { + return false; + } +}; From c93fe25d0b748dc965c4733ed29d0bba5d8a3f91 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Wed, 25 Jun 2025 15:33:08 +0900 Subject: [PATCH 05/17] refactor(ts): JWT auth handler and main passport handler --- src/config/types.ts | 1 + src/service/passport/activeDirectory.ts | 2 +- src/service/passport/index.js | 36 -------------- src/service/passport/index.ts | 38 +++++++++++++++ src/service/passport/jwtAuthHandler.js | 53 -------------------- src/service/passport/jwtAuthHandler.ts | 65 +++++++++++++++++++++++++ src/service/passport/types.ts | 39 +++++++++++++++ 7 files changed, 144 insertions(+), 90 deletions(-) delete mode 100644 src/service/passport/index.js create mode 100644 src/service/passport/index.ts delete mode 100644 src/service/passport/jwtAuthHandler.js create mode 100644 src/service/passport/jwtAuthHandler.ts create mode 100644 src/service/passport/types.ts diff --git a/src/config/types.ts b/src/config/types.ts index 02b32c073..6d8bb83a3 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -80,6 +80,7 @@ export interface JwtConfig { clientID: string; authorityURL: string; roleMapping: Record; + expectedAudience?: string; } export interface TempPasswordConfig { diff --git a/src/service/passport/activeDirectory.ts b/src/service/passport/activeDirectory.ts index ebb4d0e95..4c7739780 100644 --- a/src/service/passport/activeDirectory.ts +++ b/src/service/passport/activeDirectory.ts @@ -6,7 +6,7 @@ import { getAuthMethods } from '../../config'; export const type = 'activedirectory'; -export const configure = (passport: PassportStatic): PassportStatic => { +export const configure = async (passport: PassportStatic): Promise => { const authMethods = getAuthMethods(); const config = authMethods.find((method) => method.type.toLowerCase() === type); diff --git a/src/service/passport/index.js b/src/service/passport/index.js deleted file mode 100644 index 72918282f..000000000 --- a/src/service/passport/index.js +++ /dev/null @@ -1,36 +0,0 @@ -const passport = require("passport"); -const local = require('./local'); -const activeDirectory = require('./activeDirectory'); -const oidc = require('./oidc'); -const config = require('../../config'); - -// Allows obtaining strategy config function and type -// Keep in mind to add AuthStrategy enum when refactoring this to TS -const authStrategies = { - local: local, - activedirectory: activeDirectory, - openidconnect: oidc, -}; - -const configure = async () => { - passport.initialize(); - - const authMethods = config.getAuthMethods(); - - for (const auth of authMethods) { - const strategy = authStrategies[auth.type.toLowerCase()]; - if (strategy && typeof strategy.configure === "function") { - await strategy.configure(passport); - } - } - - if (authMethods.some(auth => auth.type.toLowerCase() === "local")) { - await local.createDefaultAdmin(); - } - - return passport; -}; - -const getPassport = () => passport; - -module.exports = { authStrategies, configure, getPassport }; diff --git a/src/service/passport/index.ts b/src/service/passport/index.ts new file mode 100644 index 000000000..465b870c5 --- /dev/null +++ b/src/service/passport/index.ts @@ -0,0 +1,38 @@ +import passport, { PassportStatic } from 'passport'; +import * as local from './local'; +import * as activeDirectory from './activeDirectory'; +import * as oidc from './oidc'; +import * as config from '../../config'; +import { Authentication } from '../../config/types'; + +type StrategyModule = { + configure: (passport: PassportStatic) => Promise; + createDefaultAdmin?: () => Promise; +}; + +export const authStrategies: Record = { + local, + activedirectory: activeDirectory, + openidconnect: oidc, +}; + +export const configure = async (): Promise => { + passport.initialize(); + + const authMethods: Authentication[] = config.getAuthMethods(); + + for (const auth of authMethods) { + const strategy = authStrategies[auth.type.toLowerCase()]; + if (strategy && typeof strategy.configure === 'function') { + await strategy.configure(passport); + } + } + + if (authMethods.some(auth => auth.type.toLowerCase() === 'local')) { + await local.createDefaultAdmin?.(); + } + + return passport; +}; + +export const getPassport = (): PassportStatic => passport; diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js deleted file mode 100644 index 32c81304d..000000000 --- a/src/service/passport/jwtAuthHandler.js +++ /dev/null @@ -1,53 +0,0 @@ -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) - * @return {Function} the middleware function - */ -const jwtAuthHandler = (overrideConfig = null) => { - return async (req, res, next) => { - const apiAuthMethods = - overrideConfig - ? [{ type: "jwt", jwtConfig: overrideConfig }] - : require('../../config').getAPIAuthMethods(); - - const jwtAuthMethod = apiAuthMethods.find((method) => method.type.toLowerCase() === "jwt"); - if (!overrideConfig && (!jwtAuthMethod || !jwtAuthMethod.enabled)) { - return next(); - } - - if (req.isAuthenticated()) { - return next(); - } - - const token = req.header("Authorization"); - if (!token) { - return res.status(401).send("No token provided\n"); - } - - const { clientID, authorityURL, expectedAudience, roleMapping } = jwtAuthMethod.jwtConfig; - const audience = expectedAudience || clientID; - - if (!authorityURL) { - return res.status(500).send("OIDC authority URL is not configured\n"); - } - - if (!clientID) { - return res.status(500).send("OIDC client ID is not configured\n"); - } - - const tokenParts = token.split(" "); - const { verifiedPayload, error } = await validateJwt(tokenParts[1], authorityURL, audience, clientID); - if (error) { - return res.status(401).send(error); - } - - req.user = verifiedPayload; - assignRoles(roleMapping, verifiedPayload, req.user); - - return next(); - } -} - -module.exports = jwtAuthHandler; diff --git a/src/service/passport/jwtAuthHandler.ts b/src/service/passport/jwtAuthHandler.ts new file mode 100644 index 000000000..b95440d1d --- /dev/null +++ b/src/service/passport/jwtAuthHandler.ts @@ -0,0 +1,65 @@ +import { assignRoles, validateJwt } from './jwtUtils'; +import { Request, Response, NextFunction } from 'express'; +import { getAPIAuthMethods } from '../../config'; +import { JwtConfig, Authentication } from '../../config/types'; +import { RoleMapping } from './types'; + +export const jwtAuthHandler = (overrideConfig: JwtConfig | null = null) => { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const apiAuthMethods: Authentication[] = overrideConfig + ? [{ type: 'jwt', enabled: true, jwtConfig: overrideConfig }] + : getAPIAuthMethods(); + + const jwtAuthMethod = apiAuthMethods.find( + (method) => method.type.toLowerCase() === 'jwt' + ); + + if (!overrideConfig && (!jwtAuthMethod || !jwtAuthMethod.enabled)) { + return next(); + } + + if (req.isAuthenticated?.()) { + return next(); + } + + const token = req.header('Authorization'); + if (!token) { + res.status(401).send('No token provided\n'); + return; + } + + const config = jwtAuthMethod!.jwtConfig!; + const { clientID, authorityURL, expectedAudience, roleMapping } = config; + const audience = expectedAudience || clientID; + + if (!authorityURL) { + res.status(500).send('OIDC authority URL is not configured\n'); + return; + } + + if (!clientID) { + res.status(500).send('OIDC client ID is not configured\n'); + return; + } + + const tokenParts = token.split(' '); + const accessToken = tokenParts.length === 2 ? tokenParts[1] : tokenParts[0]; + + const { verifiedPayload, error } = await validateJwt( + accessToken, + authorityURL, + audience, + clientID + ); + + if (error || !verifiedPayload) { + res.status(401).send(error || 'JWT validation failed\n'); + return; + } + + req.user = verifiedPayload; + assignRoles(roleMapping as RoleMapping, verifiedPayload, req.user); + + next(); + }; +}; diff --git a/src/service/passport/types.ts b/src/service/passport/types.ts new file mode 100644 index 000000000..0970ceb0b --- /dev/null +++ b/src/service/passport/types.ts @@ -0,0 +1,39 @@ +import { JwtPayload } from "jsonwebtoken"; + +export type JwkKey = { + kty: string; + kid: string; + use: string; + n?: string; + e?: string; + x5c?: string[]; + [key: string]: any; +}; + +export type JwksResponse = { + keys: JwkKey[]; +}; + +export type JwtValidationResult = { + verifiedPayload: JwtPayload | null; + error: string | null; +} + +/** + * The JWT role mapping configuration. + * + * The key is the in-app role name (e.g. "admin"). + * The value is a pair of claim name and expected value. + * + * For example, the following role mapping will assign the "admin" role to users whose "name" claim is "John Doe": + * + * { + * "admin": { + * "name": "John Doe" + * } + * } + */ +export type RoleMapping = Record< + string, // role name like "admin" + Record // e.g. { "name": "John Doe" } +>; From 4706ee5b69d01ac2aec560ed34ade0f8214a82fe Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Wed, 25 Jun 2025 15:33:45 +0900 Subject: [PATCH 06/17] chore(ts): add types for JWT-related libraries --- package-lock.json | 27 +++++++++++++++++++++++++++ package.json | 2 ++ 2 files changed, 29 insertions(+) diff --git a/package-lock.json b/package-lock.json index ff96f49b3..7b3907e77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,8 @@ "@commitlint/config-conventional": "^19.0.0", "@types/express": "^5.0.1", "@types/express-http-proxy": "^1.6.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/jwk-to-pem": "^2.0.3", "@types/lodash": "^4.17.15", "@types/mocha": "^10.0.10", "@types/node": "^22.13.5", @@ -3738,6 +3740,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/jwk-to-pem": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/jwk-to-pem/-/jwk-to-pem-2.0.3.tgz", + "integrity": "sha512-I/WFyFgk5GrNbkpmt14auGO3yFK1Wt4jXzkLuI+fDBNtO5ZI2rbymyGd6bKzfSBEuyRdM64ZUwxU1+eDcPSOEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/lodash": { "version": "4.17.16", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz", @@ -3758,6 +3778,13 @@ "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", "dev": true }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.13.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", diff --git a/package.json b/package.json index d5d954d41..3b512213b 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,8 @@ "@commitlint/config-conventional": "^19.0.0", "@types/express": "^5.0.1", "@types/express-http-proxy": "^1.6.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/jwk-to-pem": "^2.0.3", "@types/lodash": "^4.17.15", "@types/mocha": "^10.0.10", "@types/node": "^22.13.5", From d94d1db43c0abc81202623d4c1aeffe6d740e57f Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Thu, 26 Jun 2025 21:16:11 +0900 Subject: [PATCH 07/17] refactor(ts): service helper functions --- index.ts | 2 +- src/service/passport/index.ts | 1 + src/service/routes/config.js | 22 ---------------------- src/service/routes/config.ts | 22 ++++++++++++++++++++++ src/service/routes/healthcheck.js | 10 ---------- src/service/routes/healthcheck.ts | 11 +++++++++++ src/service/routes/home.js | 14 -------------- src/service/routes/home.ts | 15 +++++++++++++++ src/service/routes/index.js | 20 -------------------- src/service/routes/index.ts | 21 +++++++++++++++++++++ test/testJwtAuthHandler.test.js | 2 +- 11 files changed, 72 insertions(+), 68 deletions(-) delete mode 100644 src/service/routes/config.js create mode 100644 src/service/routes/config.ts delete mode 100644 src/service/routes/healthcheck.js create mode 100644 src/service/routes/healthcheck.ts delete mode 100644 src/service/routes/home.js create mode 100644 src/service/routes/home.ts delete mode 100644 src/service/routes/index.js create mode 100644 src/service/routes/index.ts diff --git a/index.ts b/index.ts index 880ccfe02..b633bd7c4 100755 --- a/index.ts +++ b/index.ts @@ -5,7 +5,7 @@ import { hideBin } from 'yargs/helpers'; import * as fs from 'fs'; import { configFile, setConfigFile, validate } from './src/config/file'; import proxy from './src/proxy'; -import service from './src/service'; +import * as service from './src/service'; const argv = yargs(hideBin(process.argv)) .usage('Usage: $0 [options]') diff --git a/src/service/passport/index.ts b/src/service/passport/index.ts index 465b870c5..07852508a 100644 --- a/src/service/passport/index.ts +++ b/src/service/passport/index.ts @@ -8,6 +8,7 @@ import { Authentication } from '../../config/types'; type StrategyModule = { configure: (passport: PassportStatic) => Promise; createDefaultAdmin?: () => Promise; + type: string; }; export const authStrategies: Record = { diff --git a/src/service/routes/config.js b/src/service/routes/config.js deleted file mode 100644 index e80d70b5b..000000000 --- a/src/service/routes/config.js +++ /dev/null @@ -1,22 +0,0 @@ -const express = require('express'); -const router = new express.Router(); - -const config = require('../../config'); - -router.get('/attestation', function ({ res }) { - res.send(config.getAttestationConfig()); -}); - -router.get('/urlShortener', function ({ res }) { - res.send(config.getURLShortener()); -}); - -router.get('/contactEmail', function ({ res }) { - res.send(config.getContactEmail()); -}); - -router.get('/uiRouteAuth', function ({ res }) { - res.send(config.getUIRouteAuth()); -}); - -module.exports = router; diff --git a/src/service/routes/config.ts b/src/service/routes/config.ts new file mode 100644 index 000000000..0d8796fde --- /dev/null +++ b/src/service/routes/config.ts @@ -0,0 +1,22 @@ +import express, { Request, Response } from 'express'; +import * as config from '../../config'; + +const router = express.Router(); + +router.get('/attestation', (_req: Request, res: Response) => { + res.send(config.getAttestationConfig()); +}); + +router.get('/urlShortener', (_req: Request, res: Response) => { + res.send(config.getURLShortener()); +}); + +router.get('/contactEmail', (_req: Request, res: Response) => { + res.send(config.getContactEmail()); +}); + +router.get('/uiRouteAuth', (_req: Request, res: Response) => { + res.send(config.getUIRouteAuth()); +}); + +export default router; diff --git a/src/service/routes/healthcheck.js b/src/service/routes/healthcheck.js deleted file mode 100644 index 4745a8275..000000000 --- a/src/service/routes/healthcheck.js +++ /dev/null @@ -1,10 +0,0 @@ -const express = require('express'); -const router = new express.Router(); - -router.get('/', function (req, res) { - res.send({ - message: 'ok', - }); -}); - -module.exports = router; diff --git a/src/service/routes/healthcheck.ts b/src/service/routes/healthcheck.ts new file mode 100644 index 000000000..c1518bec0 --- /dev/null +++ b/src/service/routes/healthcheck.ts @@ -0,0 +1,11 @@ +import express, { Request, Response } from 'express'; + +const router = express.Router(); + +router.get('/', function (req: Request, res: Response) { + res.send({ + message: 'ok', + }); +}); + +export default router; diff --git a/src/service/routes/home.js b/src/service/routes/home.js deleted file mode 100644 index ce11503f6..000000000 --- a/src/service/routes/home.js +++ /dev/null @@ -1,14 +0,0 @@ -const express = require('express'); -const router = new express.Router(); - -const resource = { - healthcheck: '/api/v1/healthcheck', - push: '/api/v1/push', - auth: '/api/auth', -}; - -router.get('/', function (req, res) { - res.send(resource); -}); - -module.exports = router; diff --git a/src/service/routes/home.ts b/src/service/routes/home.ts new file mode 100644 index 000000000..07a4d5b06 --- /dev/null +++ b/src/service/routes/home.ts @@ -0,0 +1,15 @@ +import express, { Request, Response } from 'express'; + +const router = express.Router(); + +const resource = { + healthcheck: '/api/v1/healthcheck', + push: '/api/v1/push', + auth: '/api/auth', +}; + +router.get('/', function (req: Request, res: Response) { + res.send(resource); +}); + +export default router; diff --git a/src/service/routes/index.js b/src/service/routes/index.js deleted file mode 100644 index 45b276c17..000000000 --- a/src/service/routes/index.js +++ /dev/null @@ -1,20 +0,0 @@ -const express = require('express'); -const auth = require('./auth'); -const push = require('./push'); -const home = require('./home'); -const repo = require('./repo'); -const users = require('./users'); -const healthcheck = require('./healthcheck'); -const config = require('./config'); -const jwtAuthHandler = require('../passport/jwtAuthHandler'); -const router = new express.Router(); - -router.use('/api', home); -router.use('/api/auth', auth); -router.use('/api/v1/healthcheck', healthcheck); -router.use('/api/v1/push', jwtAuthHandler(), push); -router.use('/api/v1/repo', jwtAuthHandler(), repo); -router.use('/api/v1/user', jwtAuthHandler(), users); -router.use('/api/v1/config', config); - -module.exports = router; diff --git a/src/service/routes/index.ts b/src/service/routes/index.ts new file mode 100644 index 000000000..197d9df6d --- /dev/null +++ b/src/service/routes/index.ts @@ -0,0 +1,21 @@ +import express from 'express'; +import auth from './auth'; +import push from './push'; +import home from './home'; +import repo from './repo'; +import users from './users'; +import healthcheck from './healthcheck'; +import config from './config'; +import { jwtAuthHandler } from '../passport/jwtAuthHandler'; + +const router = express.Router(); + +router.use('/api', home); +router.use('/api/auth', auth); +router.use('/api/v1/healthcheck', healthcheck); +router.use('/api/v1/push', jwtAuthHandler(), push); +router.use('/api/v1/repo', jwtAuthHandler(), repo); +router.use('/api/v1/user', jwtAuthHandler(), users); +router.use('/api/v1/config', config); + +export default router; \ No newline at end of file diff --git a/test/testJwtAuthHandler.test.js b/test/testJwtAuthHandler.test.js index af2bb1bb2..ffb29458d 100644 --- a/test/testJwtAuthHandler.test.js +++ b/test/testJwtAuthHandler.test.js @@ -5,7 +5,7 @@ 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'); +const { jwtAuthHandler } = require('../src/service/passport/jwtAuthHandler'); describe('getJwks', () => { it('should fetch JWKS keys from authority', async () => { From 19049eb8346892a3d58bd3bd6884a793745c33b1 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Thu, 26 Jun 2025 21:49:53 +0900 Subject: [PATCH 08/17] refactor(ts): service module auth routes --- src/service/routes/auth.js | 151 ------------------------------------- src/service/routes/auth.ts | 150 ++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 151 deletions(-) delete mode 100644 src/service/routes/auth.js create mode 100644 src/service/routes/auth.ts diff --git a/src/service/routes/auth.js b/src/service/routes/auth.js deleted file mode 100644 index 9544931d9..000000000 --- a/src/service/routes/auth.js +++ /dev/null @@ -1,151 +0,0 @@ -const express = require('express'); -const router = new express.Router(); -const passport = require('../passport').getPassport(); -const authStrategies = require('../passport').authStrategies; -const db = require('../../db'); -const { GIT_PROXY_UI_HOST: uiHost = 'http://localhost', GIT_PROXY_UI_PORT: uiPort = 3000 } = - process.env; - -router.get('/', (req, res) => { - res.status(200).json({ - login: { - action: 'post', - uri: '/api/auth/login', - }, - profile: { - action: 'get', - uri: '/api/auth/profile', - }, - logout: { - action: 'post', - uri: '/api/auth/logout', - }, - }); -}); - -router.post('/login', passport.authenticate(authStrategies['local'].type), async (req, res) => { - try { - const currentUser = { ...req.user }; - delete currentUser.password; - console.log( - `serivce.routes.auth.login: user logged in, username=${ - currentUser.username - } profile=${JSON.stringify(currentUser)}`, - ); - res.send({ - message: 'success', - user: currentUser, - }); - } catch (e) { - console.log(`service.routes.auth.login: Error logging user in ${JSON.stringify(e)}`); - res.status(500).send('Failed to login').end(); - return; - } -}); - -router.get('/oidc', passport.authenticate(authStrategies['openidconnect'].type)); - -router.get('/oidc/callback', (req, res, next) => { - passport.authenticate(authStrategies['openidconnect'].type, (err, user, info) => { - if (err) { - console.error('Authentication error:', err); - return res.status(401).end(); - } - if (!user) { - console.error('No user found:', info); - return res.status(401).end(); - } - req.logIn(user, (err) => { - if (err) { - console.error('Login error:', err); - return res.status(401).end(); - } - console.log('Logged in successfully. User:', user); - return res.redirect(`${uiHost}:${uiPort}/dashboard/profile`); - }); - })(req, res, next); -}); - -// when login is successful, retrieve user info -router.get('/success', (req, res) => { - console.log('authenticated' + JSON.stringify(req.user)); - if (req.user) { - res.json({ - success: true, - message: 'user has successfully authenticated', - user: req.user, - cookies: req.cookies, - }); - } else { - res.status(401).end(); - } -}); - -// when login failed, send failed msg -router.get('failed', (req, res) => { - res.status(401).json({ - success: false, - message: 'user failed to authenticate.', - }); -}); - -router.post('/logout', (req, res, next) => { - req.logout(req.user, (err) => { - if (err) return next(err); - }); - res.clearCookie('connect.sid'); - res.send({ isAuth: req.isAuthenticated(), user: req.user }); -}); - -router.get('/profile', async (req, res) => { - if (req.user) { - const userVal = await db.findUser(req.user.username); - delete userVal.password; - res.send(userVal); - } else { - res.status(401).end(); - } -}); - -router.post('/gitAccount', async (req, res) => { - if (req.user) { - try { - let login = - req.body.username == null || req.body.username == 'undefined' - ? req.body.id - : req.body.username; - - login = login.split('@')[0]; - - const user = await db.findUser(login); - - console.log('Adding gitAccount' + req.body.gitAccount); - user.gitAccount = req.body.gitAccount; - db.updateUser(user); - res.status(200).end(); - } catch { - res - .status(500) - .send({ - message: 'An error occurred', - }) - .end(); - } - } else { - res.status(401).end(); - } -}); - -router.get('/me', async (req, res) => { - if (req.user) { - const user = JSON.parse(JSON.stringify(req.user)); - if (user && user.password) delete user.password; - const login = user.username; - const userVal = await db.findUser(login); - if (userVal && userVal.password) delete userVal.password; - res.send(userVal); - } else { - res.status(401).end(); - } -}); -module.exports = router; diff --git a/src/service/routes/auth.ts b/src/service/routes/auth.ts new file mode 100644 index 000000000..44e35ae41 --- /dev/null +++ b/src/service/routes/auth.ts @@ -0,0 +1,150 @@ +import express, { Request, Response, NextFunction } from 'express'; +import { getPassport, authStrategies } from '../passport'; +import * as db from '../../db'; +import { User } from '../../db/types'; + +const router = express.Router(); +const passport = getPassport(); + +const { + GIT_PROXY_UI_HOST: uiHost = 'http://localhost', + GIT_PROXY_UI_PORT: uiPort = '3000', +} = process.env; + +router.get('/', (_req: Request, res: Response) => { + res.status(200).json({ + login: { action: 'post', uri: '/api/auth/login' }, + profile: { action: 'get', uri: '/api/auth/profile' }, + logout: { action: 'post', uri: '/api/auth/logout' }, + }); +}); + +router.post( + '/login', + passport.authenticate(authStrategies['local'].type), + async (req: Request, res: Response) => { + try { + const currentUser = { ...req.user } as User; + delete (currentUser as any).password; + + console.log( + `service.routes.auth.login: user logged in, username=${currentUser.username}, profile=${JSON.stringify( + currentUser + )}` + ); + + res.send({ message: 'success', user: currentUser }); + } catch (e) { + console.error(`service.routes.auth.login: Error logging user in`, e); + res.status(500).send('Failed to login').end(); + } + } +); + +router.get( + '/oidc', + passport.authenticate(authStrategies['openidconnect'].type) +); + +router.get('/oidc/callback', (req: Request, res: Response, next: NextFunction) => { + passport.authenticate(authStrategies['openidconnect'].type, (err: any, user: any, info: any) => { + if (err) { + console.error('Authentication error:', err); + return res.status(401).end(); + } + if (!user) { + console.error('No user found:', info); + return res.status(401).end(); + } + + req.logIn(user, (err) => { + if (err) { + console.error('Login error:', err); + return res.status(401).end(); + } + + console.log('Logged in successfully. User:', user); + return res.redirect(`${uiHost}:${uiPort}/dashboard/profile`); + }); + })(req, res, next); +}); + +router.get('/success', (req: Request, res: Response) => { + console.log('authenticated', JSON.stringify(req.user)); + if (req.user) { + res.json({ + success: true, + message: 'user has successfully authenticated', + user: req.user, + cookies: req.cookies, + }); + } else { + res.status(401).end(); + } +}); + +router.get('/failed', (_req: Request, res: Response) => { + res.status(401).json({ + success: false, + message: 'user failed to authenticate.', + }); +}); + +router.post('/logout', (req: Request, res: Response, next: NextFunction) => { + req.logout((err) => { + if (err) return next(err); + res.clearCookie('connect.sid'); + res.send({ isAuth: req.isAuthenticated?.(), user: req.user }); + }); +}); + +router.get('/profile', async (req: Request, res: Response) => { + if (req.user) { + const userVal = await db.findUser((req.user as User).username); + delete (userVal as any).password; + res.send(userVal); + } else { + res.status(401).end(); + } +}); + +router.post('/gitAccount', async (req: Request, res: Response) => { + if (req.user) { + try { + let login = + req.body.username == null || req.body.username === 'undefined' + ? req.body.id + : req.body.username; + + login = login.split('@')[0]; + + const user = await db.findUser(login); + console.log('Adding gitAccount:', req.body.gitAccount); + + user.gitAccount = req.body.gitAccount; + await db.updateUser(user); + res.status(200).end(); + } catch { + res.status(500).send({ message: 'An error occurred' }).end(); + } + } else { + res.status(401).end(); + } +}); + +router.get('/me', async (req: Request, res: Response) => { + if (req.user) { + const user = JSON.parse(JSON.stringify(req.user)); + if (user && user.password) delete user.password; + + const login = user.username; + const userVal = await db.findUser(login); + + if (userVal?.password) delete userVal.password; + res.send(userVal); + } else { + res.status(401).end(); + } +}); + +export default router; From a2b5aae25e40ab68c8ec0e55877bb0776da0e013 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 27 Jun 2025 15:43:22 +0900 Subject: [PATCH 09/17] refactor(ts): service index file --- src/service/index.js | 123 ------------------------------------------- src/service/index.ts | 92 ++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 123 deletions(-) delete mode 100644 src/service/index.js create mode 100644 src/service/index.ts diff --git a/src/service/index.js b/src/service/index.js deleted file mode 100644 index 02e416aa0..000000000 --- a/src/service/index.js +++ /dev/null @@ -1,123 +0,0 @@ -const express = require('express'); -const session = require('express-session'); -const http = require('http'); -const cors = require('cors'); -const app = express(); -const path = require('path'); -const config = require('../config'); -const db = require('../db'); -const rateLimit = require('express-rate-limit'); -const lusca = require('lusca'); -const configLoader = require('../config/ConfigLoader'); -const proxy = require('../proxy'); - -const limiter = rateLimit(config.getRateLimit()); - -const { GIT_PROXY_UI_PORT: uiPort } = require('../config/env').serverConfig; - -const _httpServer = http.createServer(app); - -const corsOptions = { - credentials: true, - origin: true, -}; - -const createApp = async () => { - // configuration of passport is async - // Before we can bind the routes - we need the passport strategy - const passport = await require('./passport').configure(); - const routes = require('./routes'); - const absBuildPath = path.join(__dirname, '../../build'); - app.use(cors(corsOptions)); - app.set('trust proxy', 1); - app.use(limiter); - - // Add new admin-only endpoint to reload config - app.post('/api/v1/admin/reload-config', async (req, res) => { - if (!req.isAuthenticated() || !req.user.admin) { - return res.status(403).json({ error: 'Unauthorized' }); - } - - try { - // 1. Reload configuration - await configLoader.loadConfiguration(); - - // 2. Stop existing services - await proxy.stop(); - - // 3. Apply new configuration - config.validate(); - - // 4. Restart services with new config - await proxy.start(); - - console.log('Configuration reloaded and services restarted successfully'); - res.json({ status: 'success', message: 'Configuration reloaded and services restarted' }); - } catch (error) { - console.error('Failed to reload configuration and restart services:', error); - - // Attempt to restart with existing config if reload fails - try { - await proxy.start(); - } catch (startError) { - console.error('Failed to restart services:', startError); - } - - res.status(500).json({ error: 'Failed to reload configuration' }); - } - }); - - app.use( - session({ - store: config.getDatabase().type === 'mongo' ? db.getSessionStore(session) : null, - secret: config.getCookieSecret(), - resave: false, - saveUninitialized: false, - cookie: { - secure: 'auto', - httpOnly: true, - maxAge: config.getSessionMaxAgeHours() * 60 * 60 * 1000, - }, - }), - ); - if (config.getCSRFProtection() && process.env.NODE_ENV !== 'test') { - app.use( - lusca({ - csrf: { - cookie: { name: 'csrf' }, - }, - hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, - nosniff: true, - referrerPolicy: 'same-origin', - xframe: 'SAMEORIGIN', - xssProtection: true, - }), - ); - } - app.use(passport.initialize()); - app.use(passport.session()); - app.use(express.json()); - app.use(express.urlencoded({ extended: true })); - app.use('/', routes); - app.use('/', express.static(absBuildPath)); - app.get('/*', (req, res) => { - res.sendFile(path.join(`${absBuildPath}/index.html`)); - }); - - return app; -}; - -const start = async () => { - const app = await createApp(); - - _httpServer.listen(uiPort); - - console.log(`Service Listening on ${uiPort}`); - app.emit('ready'); - - return app; -}; - -module.exports.createApp = createApp; -module.exports.start = start; -module.exports.httpServer = _httpServer; diff --git a/src/service/index.ts b/src/service/index.ts new file mode 100644 index 000000000..f169f8674 --- /dev/null +++ b/src/service/index.ts @@ -0,0 +1,92 @@ +import express, { Express, Request, Response } from 'express'; +import session from 'express-session'; +import http from 'http'; +import cors from 'cors'; +import path from 'path'; +import rateLimit from 'express-rate-limit'; +import lusca from 'lusca'; + +import * as config from '../config'; +import * as db from '../db'; +import * as configLoader from '../config/ConfigLoader'; +import proxy from '../proxy'; +import { configure as configurePassport } from './passport'; +import routes from './routes'; +import { serverConfig } from '../config/env'; + +const app: Express = express(); +const limiter = rateLimit(config.getRateLimit()); +const { GIT_PROXY_UI_PORT: uiPort } = serverConfig; + +const _httpServer = http.createServer(app); + +const corsOptions = { + credentials: true, + origin: true, +}; + +export const createApp = async (): Promise => { + const passport = await configurePassport(); + const absBuildPath = path.join(__dirname, '../../build'); + + app.use(cors(corsOptions)); + app.set('trust proxy', 1); + app.use(limiter); + + const sessionStore = + config.getDatabase().type === 'mongo' ? db.getSessionStore(session) : undefined; + + app.use( + session({ + store: sessionStore, + secret: config.getCookieSecret(), + resave: false, + saveUninitialized: false, + cookie: { + secure: 'auto', + httpOnly: true, + maxAge: config.getSessionMaxAgeHours() * 60 * 60 * 1000, + }, + }), + ); + + if (config.getCSRFProtection() && process.env.NODE_ENV !== 'test') { + app.use( + lusca({ + csrf: { + cookie: { name: 'csrf' }, + }, + hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, + nosniff: true, + referrerPolicy: 'same-origin', + xframe: 'SAMEORIGIN', + xssProtection: true, + }), + ); + } + + app.use(passport.initialize()); + app.use(passport.session()); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + app.use('/', routes); + app.use('/', express.static(absBuildPath)); + app.get('/*', (_req, res) => { + res.sendFile(path.join(`${absBuildPath}/index.html`)); + }); + + return app; +}; + +export const start = async (): Promise => { + const app = await createApp(); + + _httpServer.listen(uiPort); + console.log(`Service Listening on ${uiPort}`); + app.emit('ready'); + + return app; +}; + +export const httpServer = _httpServer; From 08269c80dca5d042f0fae0b0cc1121817ec8eb4f Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 27 Jun 2025 19:14:07 +0900 Subject: [PATCH 10/17] ts(refactor): service push routes --- src/service/routes/push.js | 184 ------------------------------------- src/service/routes/push.ts | 138 ++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 184 deletions(-) delete mode 100644 src/service/routes/push.js create mode 100644 src/service/routes/push.ts diff --git a/src/service/routes/push.js b/src/service/routes/push.js deleted file mode 100644 index 9750375ca..000000000 --- a/src/service/routes/push.js +++ /dev/null @@ -1,184 +0,0 @@ -const express = require('express'); -const router = new express.Router(); -const db = require('../../db'); - -router.get('/', async (req, res) => { - const query = { - type: 'push', - }; - - for (const k in req.query) { - if (!k) continue; - - if (k === 'limit') continue; - if (k === 'skip') continue; - let v = req.query[k]; - if (v === 'false') v = false; - if (v === 'true') v = true; - query[k] = v; - } - - res.send(await db.getPushes(query)); -}); - -router.get('/:id', async (req, res) => { - const id = req.params.id; - const push = await db.getPush(id); - if (push) { - res.send(push); - } else { - res.status(404).send({ - message: 'not found', - }); - } -}); - -router.post('/:id/reject', async (req, res) => { - if (req.user) { - const id = req.params.id; - console.log({ id }); - - // Get the push request - const push = await db.getPush(id); - console.log({ push }); - - // Get the Internal Author of the push via their Git Account name - const gitAccountauthor = push.user; - const list = await db.getUsers({ gitAccount: gitAccountauthor }); - console.log({ list }); - - if (list.length === 0) { - res.status(401).send({ - message: `The git account ${gitAccountauthor} could not be found`, - }); - return; - } - - if (list[0].username.toLowerCase() === req.user.username.toLowerCase() && !list[0].admin) { - res.status(401).send({ - message: `Cannot reject your own changes`, - }); - return; - } - - const isAllowed = await db.canUserApproveRejectPush(id, req.user.username); - console.log({ isAllowed }); - - if (isAllowed) { - const result = await db.reject(id); - console.log(`user ${req.user.username} rejected push request for ${id}`); - res.send(result); - } else { - res.status(401).send({ - message: 'User is not authorised to reject changes', - }); - } - } else { - res.status(401).send({ - message: 'not logged in', - }); - } -}); - -router.post('/:id/authorise', async (req, res) => { - console.log({ req }); - - const questions = req.body.params?.attestation; - console.log({ questions }); - - const attestationComplete = questions?.every((question) => !!question.checked); - console.log({ attestationComplete }); - - if (req.user && attestationComplete) { - const id = req.params.id; - console.log({ id }); - - // Get the push request - const push = await db.getPush(id); - console.log({ push }); - - // Get the Internal Author of the push via their Git Account name - const gitAccountauthor = push.user; - const list = await db.getUsers({ gitAccount: gitAccountauthor }); - console.log({ list }); - - if (list.length === 0) { - res.status(401).send({ - message: `The git account ${gitAccountauthor} could not be found`, - }); - return; - } - - if (list[0].username.toLowerCase() === req.user.username.toLowerCase() && !list[0].admin) { - res.status(401).send({ - message: `Cannot approve your own changes`, - }); - return; - } - - // If we are not the author, now check that we are allowed to authorise on this - // repo - const isAllowed = await db.canUserApproveRejectPush(id, req.user.username); - if (isAllowed) { - console.log(`user ${req.user.username} approved push request for ${id}`); - - const reviewerList = await db.getUsers({ username: req.user.username }); - console.log({ reviewerList }); - - const reviewerGitAccount = reviewerList[0].gitAccount; - console.log({ reviewerGitAccount }); - - if (!reviewerGitAccount) { - res.status(401).send({ - message: 'You must associate a GitHub account with your user before approving...', - }); - return; - } - - const attestation = { - questions, - timestamp: new Date(), - reviewer: { - username: req.user.username, - gitAccount: reviewerGitAccount, - }, - }; - const result = await db.authorise(id, attestation); - res.send(result); - } else { - res.status(401).send({ - message: `user ${req.user.username} not authorised to approve push's on this project`, - }); - } - } else { - res.status(401).send({ - message: 'You are unauthorized to perform this action...', - }); - } -}); - -router.post('/:id/cancel', async (req, res) => { - if (req.user) { - const id = req.params.id; - - const isAllowed = await db.canUserCancelPush(id, req.user.username); - - if (isAllowed) { - const result = await db.cancel(id); - console.log(`user ${req.user.username} canceled push request for ${id}`); - res.send(result); - } else { - console.log(`user ${req.user.username} not authorised to cancel push request for ${id}`); - res.status(401).send({ - message: - 'User ${req.user.username)} not authorised to cancel push requests on this project.', - }); - } - } else { - res.status(401).send({ - message: 'not logged in', - }); - } -}); - -module.exports = router; diff --git a/src/service/routes/push.ts b/src/service/routes/push.ts new file mode 100644 index 000000000..cd8430710 --- /dev/null +++ b/src/service/routes/push.ts @@ -0,0 +1,138 @@ +import express, { Request, Response, NextFunction } from 'express'; +import * as db from '../../db'; +import { User } from '../../db/types'; + +interface AuthenticatedRequest extends Request { + user: User; +} + +const router = express.Router(); + +router.get('/', async (req: Request, res: Response) => { + const query: Record = { type: 'push' }; + + for (const k in req.query) { + if (!k || k === 'limit' || k === 'skip') continue; + let v = req.query[k]; + if (v === 'false') v = false as any; + if (v === 'true') v = true as any; + query[k] = v; + } + + res.send(await db.getPushes(query)); +}); + +router.get('/:id', async (req: Request, res: Response) => { + const { id } = req.params; + const push = await db.getPush(id); + if (push) { + res.send(push); + } else { + res.status(404).send({ message: 'not found' }); + } +}); + +router.post('/:id/reject', async (req: AuthenticatedRequest, res: any) => { + try { + if (!req.user) { + return res.status(401).send({ message: 'not logged in' }); + } + + const { id } = req.params; + const push = await db.getPush(id); + const gitAccountAuthor = push.user; + const users = await db.getUsers({ gitAccount: gitAccountAuthor }); + + if (users.length === 0) { + return res.status(401).send({ message: `The git account ${gitAccountAuthor} could not be found` }); + } + + if (users[0].username.toLowerCase() === req.user.username.toLowerCase() && !users[0].admin) { + return res.status(401).send({ message: `Cannot reject your own changes` }); + } + + const isAllowed = await db.canUserApproveRejectPush(id, req.user.username); + if (isAllowed) { + const result = await db.reject(id); + return res.status(200).send(result); + } else { + return res.status(401).send({ message: 'User is not authorised to reject changes' }); + } + } catch (error) { + console.error('Error rejecting push:', error); + return res.status(500).send({ message: 'Internal server error' }); + } +}); + +router.post('/:id/authorise', async (req: AuthenticatedRequest, res: any) => { + const questions = req.body.params?.attestation; + + const attestationComplete = Array.isArray(questions) + ? questions.every((q) => !!q.checked) + : false; + + if (!req.user || !attestationComplete) { + return res.status(401).send({ message: 'You are unauthorized to perform this action...' }); + } + + const { id } = req.params; + const push = await db.getPush(id); + const gitAccountAuthor = push.user; + const users = await db.getUsers({ gitAccount: gitAccountAuthor }); + + if (users.length === 0) { + return res.status(401).send({ message: `The git account ${gitAccountAuthor} could not be found` }); + } + + if (users[0].username.toLowerCase() === req.user.username.toLowerCase() && !users[0].admin) { + return res.status(401).send({ message: `Cannot approve your own changes` }); + } + + const isAllowed = await db.canUserApproveRejectPush(id, req.user.username); + if (!isAllowed) { + return res.status(401).send({ + message: `user ${req.user.username} not authorised to approve push's on this project`, + }); + } + + const reviewerList = await db.getUsers({ username: req.user.username }); + const reviewerGitAccount = reviewerList[0].gitAccount; + + if (!reviewerGitAccount) { + return res.status(401).send({ + message: 'You must associate a GitHub account with your user before approving...', + }); + } + + const attestation = { + questions, + timestamp: new Date(), + reviewer: { + username: req.user.username, + gitAccount: reviewerGitAccount, + }, + }; + + const result = await db.authorise(id, attestation); + res.send(result); +}); + +router.post('/:id/cancel', async (req: AuthenticatedRequest, res: any) => { + if (!req.user) { + return res.status(401).send({ message: 'not logged in' }); + } + + const { id } = req.params; + const isAllowed = await db.canUserCancelPush(id, req.user.username); + + if (isAllowed) { + const result = await db.cancel(id); + res.send(result); + } else { + res.status(401).send({ + message: `User ${req.user.username} not authorised to cancel push requests on this project.`, + }); + } +}); + +export default router; From 885c061134cf3bfb61ab66285766c5cf16fe6a4f Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 27 Jun 2025 19:15:02 +0900 Subject: [PATCH 11/17] refactor(ts): service module helpers and minor fixes --- src/config/index.ts | 2 +- src/service/index.ts | 2 +- src/service/routes/index.ts | 2 +- src/service/urls.js | 20 -------------------- src/service/urls.ts | 21 +++++++++++++++++++++ 5 files changed, 24 insertions(+), 23 deletions(-) delete mode 100644 src/service/urls.js create mode 100644 src/service/urls.ts diff --git a/src/config/index.ts b/src/config/index.ts index 20d1561d0..669d8d53a 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -32,7 +32,7 @@ let _privateOrganizations: string[] = defaultSettings.privateOrganizations; let _urlShortener: string = defaultSettings.urlShortener; let _contactEmail: string = defaultSettings.contactEmail; let _csrfProtection: boolean = defaultSettings.csrfProtection; -let _domains: Record = defaultSettings.domains; +let _domains: Record = defaultSettings.domains; let _rateLimit: RateLimitConfig = defaultSettings.rateLimit; // These are not always present in the default config file, so casting is required diff --git a/src/service/index.ts b/src/service/index.ts index f169f8674..841eb4624 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -1,4 +1,4 @@ -import express, { Express, Request, Response } from 'express'; +import express, { Express } from 'express'; import session from 'express-session'; import http from 'http'; import cors from 'cors'; diff --git a/src/service/routes/index.ts b/src/service/routes/index.ts index 197d9df6d..2b760afb0 100644 --- a/src/service/routes/index.ts +++ b/src/service/routes/index.ts @@ -18,4 +18,4 @@ router.use('/api/v1/repo', jwtAuthHandler(), repo); router.use('/api/v1/user', jwtAuthHandler(), users); router.use('/api/v1/config', config); -export default router; \ No newline at end of file +export default router; diff --git a/src/service/urls.js b/src/service/urls.js deleted file mode 100644 index 2d1a60de9..000000000 --- a/src/service/urls.js +++ /dev/null @@ -1,20 +0,0 @@ -const { GIT_PROXY_SERVER_PORT: PROXY_HTTP_PORT, GIT_PROXY_UI_PORT: UI_PORT } = - require('../config/env').serverConfig; -const config = require('../config'); - -module.exports = { - getProxyURL: (req) => { - const defaultURL = `${req.protocol}://${req.headers.host}`.replace( - `:${UI_PORT}`, - `:${PROXY_HTTP_PORT}`, - ); - return config.getDomains().proxy ?? defaultURL; - }, - getServiceUIURL: (req) => { - const defaultURL = `${req.protocol}://${req.headers.host}`.replace( - `:${PROXY_HTTP_PORT}`, - `:${UI_PORT}`, - ); - return config.getDomains().service ?? defaultURL; - }, -}; diff --git a/src/service/urls.ts b/src/service/urls.ts new file mode 100644 index 000000000..3609bb2dc --- /dev/null +++ b/src/service/urls.ts @@ -0,0 +1,21 @@ +import { serverConfig } from '../config/env'; +import * as config from '../config'; +import { Request } from 'express'; + +const { GIT_PROXY_SERVER_PORT: PROXY_HTTP_PORT, GIT_PROXY_UI_PORT: UI_PORT } = serverConfig; + +export function getProxyURL(req: Request): string { + const defaultURL = `${req.protocol}://${req.headers.host}`.replace( + `:${UI_PORT}`, + `:${PROXY_HTTP_PORT}`, + ); + return config.getDomains().proxy ?? defaultURL; +} + +export function getServiceUIURL(req: Request): string { + const defaultURL = `${req.protocol}://${req.headers.host}`.replace( + `:${PROXY_HTTP_PORT}`, + `:${UI_PORT}`, + ); + return config.getDomains().service ?? defaultURL; +} From c7502b2ca37880c635d4c222b44874a06786c785 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 27 Jun 2025 20:19:55 +0900 Subject: [PATCH 12/17] refactor(ts): service repo routes, add missing library types --- package-lock.json | 33 ++++++++ package.json | 3 + src/service/routes/push.ts | 8 +- src/service/routes/repo.js | 154 ------------------------------------ src/service/routes/repo.ts | 125 +++++++++++++++++++++++++++++ src/service/routes/types.ts | 6 ++ 6 files changed, 169 insertions(+), 160 deletions(-) delete mode 100644 src/service/routes/repo.js create mode 100644 src/service/routes/repo.ts create mode 100644 src/service/routes/types.ts diff --git a/package-lock.json b/package-lock.json index 7b3907e77..b9573b8b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,11 +66,14 @@ "@babel/preset-react": "^7.22.5", "@commitlint/cli": "^19.0.0", "@commitlint/config-conventional": "^19.0.0", + "@types/cors": "^2.8.19", "@types/express": "^5.0.1", "@types/express-http-proxy": "^1.6.6", + "@types/express-session": "^1.18.2", "@types/jsonwebtoken": "^9.0.10", "@types/jwk-to-pem": "^2.0.3", "@types/lodash": "^4.17.15", + "@types/lusca": "^1.7.5", "@types/mocha": "^10.0.10", "@types/node": "^22.13.5", "@types/passport-local": "^1.0.38", @@ -3698,6 +3701,16 @@ "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", "dev": true }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/express": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", @@ -3733,6 +3746,16 @@ "@types/send": "*" } }, + "node_modules/@types/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", @@ -3765,6 +3788,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lusca": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@types/lusca/-/lusca-1.7.5.tgz", + "integrity": "sha512-l49gAf8pu2iMzbKejLcz6Pqj+51H2na6BgORv1ElnE8ByPFcBdh/eZ0WNR1Va/6ZuNSZa01Hoy1DTZ3IZ+y+kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", diff --git a/package.json b/package.json index 3b512213b..127ad041c 100644 --- a/package.json +++ b/package.json @@ -88,11 +88,14 @@ "@babel/preset-react": "^7.22.5", "@commitlint/cli": "^19.0.0", "@commitlint/config-conventional": "^19.0.0", + "@types/cors": "^2.8.19", "@types/express": "^5.0.1", "@types/express-http-proxy": "^1.6.6", + "@types/express-session": "^1.18.2", "@types/jsonwebtoken": "^9.0.10", "@types/jwk-to-pem": "^2.0.3", "@types/lodash": "^4.17.15", + "@types/lusca": "^1.7.5", "@types/mocha": "^10.0.10", "@types/node": "^22.13.5", "@types/passport-local": "^1.0.38", diff --git a/src/service/routes/push.ts b/src/service/routes/push.ts index cd8430710..0178fd504 100644 --- a/src/service/routes/push.ts +++ b/src/service/routes/push.ts @@ -1,10 +1,6 @@ -import express, { Request, Response, NextFunction } from 'express'; +import express, { Request, Response } from 'express'; import * as db from '../../db'; -import { User } from '../../db/types'; - -interface AuthenticatedRequest extends Request { - user: User; -} +import { AuthenticatedRequest } from './types'; const router = express.Router(); diff --git a/src/service/routes/repo.js b/src/service/routes/repo.js deleted file mode 100644 index cc70cec16..000000000 --- a/src/service/routes/repo.js +++ /dev/null @@ -1,154 +0,0 @@ -const express = require('express'); -const router = new express.Router(); -const db = require('../../db'); -const { getProxyURL } = require('../urls'); - -router.get('/', async (req, res) => { - const proxyURL = getProxyURL(req); - const query = {}; - - for (const k in req.query) { - if (!k) continue; - - if (k === 'limit') continue; - if (k === 'skip') continue; - let v = req.query[k]; - if (v === 'false') v = false; - if (v === 'true') v = true; - query[k] = v; - } - - const qd = await db.getRepos(query); - res.send(qd.map((d) => ({ ...d, proxyURL }))); -}); - -router.get('/:name', async (req, res) => { - const proxyURL = getProxyURL(req); - const name = req.params.name; - const qd = await db.getRepo(name); - res.send({ ...qd, proxyURL }); -}); - -router.patch('/:name/user/push', async (req, res) => { - if (req.user && req.user.admin) { - const repoName = req.params.name; - const username = req.body.username.toLowerCase(); - const user = await db.findUser(username); - - if (!user) { - res.status(400).send({ error: 'User does not exist' }); - return; - } - - await db.addUserCanPush(repoName, username); - res.send({ message: 'created' }); - } else { - res.status(401).send({ - message: 'You are not authorised to perform this action...', - }); - } -}); - -router.patch('/:name/user/authorise', async (req, res) => { - if (req.user && req.user.admin) { - const repoName = req.params.name; - const username = req.body.username; - const user = await db.findUser(username); - - if (!user) { - res.status(400).send({ error: 'User does not exist' }); - return; - } - - await db.addUserCanAuthorise(repoName, username); - res.send({ message: 'created' }); - } else { - res.status(401).send({ - message: 'You are not authorised to perform this action...', - }); - } -}); - -router.delete('/:name/user/authorise/:username', async (req, res) => { - if (req.user && req.user.admin) { - const repoName = req.params.name; - const username = req.params.username; - const user = await db.findUser(username); - - if (!user) { - res.status(400).send({ error: 'User does not exist' }); - return; - } - - await db.removeUserCanAuthorise(repoName, username); - res.send({ message: 'created' }); - } else { - res.status(401).send({ - message: 'You are not authorised to perform this action...', - }); - } -}); - -router.delete('/:name/user/push/:username', async (req, res) => { - if (req.user && req.user.admin) { - const repoName = req.params.name; - const username = req.params.username; - const user = await db.findUser(username); - - if (!user) { - res.status(400).send({ error: 'User does not exist' }); - return; - } - - await db.removeUserCanPush(repoName, username); - res.send({ message: 'created' }); - } else { - res.status(401).send({ - message: 'You are not authorised to perform this action...', - }); - } -}); - -router.delete('/:name/delete', async (req, res) => { - if (req.user && req.user.admin) { - const repoName = req.params.name; - - await db.deleteRepo(repoName); - res.send({ message: 'deleted' }); - } else { - res.status(401).send({ - message: 'You are not authorised to perform this action...', - }); - } -}); - -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({ - message: 'Repository already exists!', - }); - } else { - try { - await db.createRepo(req.body); - res.send({ message: 'created' }); - } catch { - res.send('Failed to create repository'); - } - } - } else { - res.status(401).send({ - message: 'You are not authorised to perform this action...', - }); - } -}); - -module.exports = router; diff --git a/src/service/routes/repo.ts b/src/service/routes/repo.ts new file mode 100644 index 000000000..e1b34d956 --- /dev/null +++ b/src/service/routes/repo.ts @@ -0,0 +1,125 @@ +import express, { Request, Response } from 'express'; +import * as db from '../../db'; +import { getProxyURL } from '../urls'; +import { AuthenticatedRequest } from './types'; + +const router = express.Router(); + +router.get('/', async (req: Request, res: Response) => { + const proxyURL = getProxyURL(req); + const query: Record = {}; + + for (const k in req.query) { + if (!k || k === 'limit' || k === 'skip') continue; + let v = req.query[k]; + if (v === 'false') v = false as any; + if (v === 'true') v = true as any; + query[k] = v; + } + + const repos = await db.getRepos(query); + res.send(repos.map((r: any) => ({ ...r, proxyURL }))); +}); + +router.get('/:name', async (req: Request, res: Response) => { + const proxyURL = getProxyURL(req); + const name = req.params.name; + const repo = await db.getRepo(name); + res.send({ ...repo, proxyURL }); +}); + +router.patch('/:name/user/push', async (req: AuthenticatedRequest, res: any) => { + if (req.user?.admin) { + const repoName = req.params.name; + const username = req.body.username.toLowerCase(); + const user = await db.findUser(username); + + if (!user) return res.status(400).send({ error: 'User does not exist' }); + + await db.addUserCanPush(repoName, username); + return res.send({ message: 'created' }); + } + + res.status(401).send({ message: 'You are not authorised to perform this action...' }); +}); + +router.patch('/:name/user/authorise', async (req: AuthenticatedRequest, res: any) => { + if (req.user?.admin) { + const repoName = req.params.name; + const username = req.body.username; + const user = await db.findUser(username); + + if (!user) return res.status(400).send({ error: 'User does not exist' }); + + await db.addUserCanAuthorise(repoName, username); + return res.send({ message: 'created' }); + } + + res.status(401).send({ message: 'You are not authorised to perform this action...' }); +}); + +router.delete('/:name/user/authorise/:username', async (req: AuthenticatedRequest, res: any) => { + if (req.user?.admin) { + const repoName = req.params.name; + const username = req.params.username; + const user = await db.findUser(username); + + if (!user) return res.status(400).send({ error: 'User does not exist' }); + + await db.removeUserCanAuthorise(repoName, username); + return res.send({ message: 'created' }); + } + + res.status(401).send({ message: 'You are not authorised to perform this action...' }); +}); + +router.delete('/:name/user/push/:username', async (req: AuthenticatedRequest, res: any) => { + if (req.user?.admin) { + const repoName = req.params.name; + const username = req.params.username; + const user = await db.findUser(username); + + if (!user) return res.status(400).send({ error: 'User does not exist' }); + + await db.removeUserCanPush(repoName, username); + return res.send({ message: 'created' }); + } + + res.status(401).send({ message: 'You are not authorised to perform this action...' }); +}); + +router.delete('/:name/delete', async (req: AuthenticatedRequest, res: any) => { + if (req.user?.admin) { + const repoName = req.params.name; + await db.deleteRepo(repoName); + return res.send({ message: 'deleted' }); + } + + res.status(401).send({ message: 'You are not authorised to perform this action...' }); +}); + +router.post('/', async (req: AuthenticatedRequest, res: any) => { + if (req.user?.admin) { + const repoName = req.body.name; + if (!repoName) { + return res.status(400).send({ message: 'Repository name is required' }); + } + + const repo = await db.getRepo(repoName); + if (repo) { + return res.status(409).send({ message: 'Repository already exists!' }); + } + + try { + await db.createRepo(req.body); + return res.send({ message: 'created' }); + } catch (error) { + console.error('Failed to create repository:', error); + return res.status(500).send({ message: 'Failed to create repository' }); + } + } + + res.status(401).send({ message: 'You are not authorised to perform this action...' }); +}); + +export default router; diff --git a/src/service/routes/types.ts b/src/service/routes/types.ts new file mode 100644 index 000000000..9c5b2239f --- /dev/null +++ b/src/service/routes/types.ts @@ -0,0 +1,6 @@ +import { Request } from 'express'; +import { User } from '../../db/types'; + +export interface AuthenticatedRequest extends Request { + user: User; +} From b632de5c078d6aaa02da0e12a1c682e9c3a8cb26 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 27 Jun 2025 21:41:56 +0900 Subject: [PATCH 13/17] refactor(ts): service module users routes --- src/service/passport/types.ts | 5 +--- src/service/routes/users.js | 32 --------------------- src/service/routes/users.ts | 53 +++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 36 deletions(-) delete mode 100644 src/service/routes/users.js create mode 100644 src/service/routes/users.ts diff --git a/src/service/passport/types.ts b/src/service/passport/types.ts index 0970ceb0b..e38da5587 100644 --- a/src/service/passport/types.ts +++ b/src/service/passport/types.ts @@ -33,7 +33,4 @@ export type JwtValidationResult = { * } * } */ -export type RoleMapping = Record< - string, // role name like "admin" - Record // e.g. { "name": "John Doe" } ->; +export type RoleMapping = Record>; diff --git a/src/service/routes/users.js b/src/service/routes/users.js deleted file mode 100644 index 118243d70..000000000 --- a/src/service/routes/users.js +++ /dev/null @@ -1,32 +0,0 @@ -const express = require('express'); -const router = new express.Router(); -const db = require('../../db'); - -router.get('/', async (req, res) => { - const query = {}; - - console.log(`fetching users = query path =${JSON.stringify(req.query)}`); - for (const k in req.query) { - if (!k) continue; - - if (k === 'limit') continue; - if (k === 'skip') continue; - let v = req.query[k]; - if (v === 'false') v = false; - if (v === 'true') v = true; - query[k] = v; - } - - res.send(await db.getUsers(query)); -}); - -router.get('/:id', async (req, res) => { - const username = req.params.id.toLowerCase(); - console.log(`Retrieving details for user: ${username}`); - const data = await db.findUser(username); - const user = JSON.parse(JSON.stringify(data)); - if (user && user.password) delete user.password; - res.send(user); -}); - -module.exports = router; diff --git a/src/service/routes/users.ts b/src/service/routes/users.ts new file mode 100644 index 000000000..c4b3b1d8c --- /dev/null +++ b/src/service/routes/users.ts @@ -0,0 +1,53 @@ +// src/service/routes/users.ts + +import express, { Request, Response } from 'express'; +import * as db from '../../db'; +import { User } from '../../db/types'; // adjust this import path to your actual User type + +const router = express.Router(); + +router.get('/', async (req: Request, res: Response) => { + const query: Record = {}; + + console.log(`Fetching users with query: ${JSON.stringify(req.query)}`); + for (const k in req.query) { + if (!k || k === 'limit' || k === 'skip') continue; + + let v = req.query[k]; + if (v === 'false') v = false as any; + if (v === 'true') v = true as any; + query[k] = v; + } + + try { + const users = await db.getUsers(query); + res.send(users); + } catch (err) { + console.error('Error fetching users:', err); + res.status(500).send({ message: 'Internal server error' }); + } +}); + +router.get('/:id', async (req: Request, res: any) => { + const username = req.params.id.toLowerCase(); + console.log(`Retrieving details for user: ${username}`); + + try { + const data = await db.findUser(username); + + if (!data) { + return res.status(404).send({ message: 'User not found' }); + } + + // Clone and sanitize user data + const user: Partial = { ...JSON.parse(JSON.stringify(data)) }; + if ('password' in user) delete user.password; + + res.send(user); + } catch (err) { + console.error('Error retrieving user:', err); + res.status(500).send({ message: 'Internal server error' }); + } +}); + +export default router; From d5ee52dcf73afc11552684bfde54c6335fc323fb Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 29 Jun 2025 11:13:40 +0900 Subject: [PATCH 14/17] chore: add AD type for passport --- src/service/passport/types.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/service/passport/types.ts b/src/service/passport/types.ts index e38da5587..e86d829f9 100644 --- a/src/service/passport/types.ts +++ b/src/service/passport/types.ts @@ -34,3 +34,11 @@ export type JwtValidationResult = { * } */ export type RoleMapping = Record>; + +export type AD = { + isUserMemberOf: ( + username: string, + groupName: string, + callback: (err: Error | null, isMember: boolean) => void + ) => void; +} From 0cfddab62399e3c5da34567d68a59424f32c969d Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sun, 29 Jun 2025 12:43:24 +0900 Subject: [PATCH 15/17] chore: fix linting issues --- .eslintrc.json | 3 ++- src/service/index.ts | 2 -- src/service/passport/jwtUtils.ts | 4 ++-- src/service/passport/oidc.ts | 12 ++++++++++-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index fb129879f..a7e652dce 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -45,7 +45,8 @@ "@typescript-eslint/no-explicit-any": "off", // temporary until TS refactor is complete "@typescript-eslint/no-unused-vars": "off", // temporary until TS refactor is complete "@typescript-eslint/no-require-imports": "off", // prevents error on old "require" imports - "@typescript-eslint/no-unused-expressions": "off" // prevents error on test "expect" expressions + "@typescript-eslint/no-unused-expressions": "off", // prevents error on test "expect" expressions + "new-cap": "off" // prevents error on express.Router() }, "settings": { "react": { diff --git a/src/service/index.ts b/src/service/index.ts index 841eb4624..778b7f960 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -8,8 +8,6 @@ import lusca from 'lusca'; import * as config from '../config'; import * as db from '../db'; -import * as configLoader from '../config/ConfigLoader'; -import proxy from '../proxy'; import { configure as configurePassport } from './passport'; import routes from './routes'; import { serverConfig } from '../config/env'; diff --git a/src/service/passport/jwtUtils.ts b/src/service/passport/jwtUtils.ts index 06cb2dac4..5784b2fa2 100644 --- a/src/service/passport/jwtUtils.ts +++ b/src/service/passport/jwtUtils.ts @@ -26,8 +26,8 @@ export async function getJwks(authorityUrl: string): Promise { * Validate a JWT token using the OIDC configuration. * @param {string} token the JWT token * @param {string} authorityUrl the OIDC authority URL - * @param {string} clientID the OIDC client ID * @param {string} expectedAudience the expected audience for the token + * @param {string} clientID the OIDC client ID * @param {Function} getJwksInject the getJwks function to use (for dependency injection). Defaults to the built-in getJwks function. * @return {Promise} the verified payload or an error */ @@ -64,7 +64,7 @@ export async function validateJwt( throw new Error('JWT client ID does not match'); } - return { verifiedPayload, error: null }; + return { verifiedPayload }; } catch (error: any) { const errorMessage = `JWT validation failed: ${error.message}\n`; console.error(errorMessage); diff --git a/src/service/passport/oidc.ts b/src/service/passport/oidc.ts index 2645e4cef..bb29b0638 100644 --- a/src/service/passport/oidc.ts +++ b/src/service/passport/oidc.ts @@ -1,6 +1,7 @@ import * as db from '../../db'; import { PassportStatic } from 'passport'; import { getAuthMethods } from '../../config/index.ts'; +import { UserInfoResponse } from 'openid-client'; export const type = 'openidconnect'; @@ -33,7 +34,7 @@ export const configure = async (passport: PassportStatic): Promise { + async (tokenSet: any, done: (err: any, user?: any) => void) => { const idTokenClaims = tokenSet.claims(); const expectedSub = idTokenClaims.sub; const userInfo = await fetchUserInfo(config, tokenSet.access_token, expectedSub); @@ -73,8 +74,11 @@ export const configure = async (passport: PassportStatic): Promise} - A promise that resolves when the user authentication is complete */ -const handleUserAuthentication = async (userInfo: any, done: Function): Promise => { +const handleUserAuthentication = async (userInfo: UserInfoResponse, done: (err: any, user?: any) => void): Promise => { console.log('handleUserAuthentication called'); try { const user = await db.findUserByOIDC(userInfo.sub); @@ -109,6 +113,8 @@ const handleUserAuthentication = async (userInfo: any, done: Function): Promise< /** * Extracts email from OIDC profile. * Different providers use different fields to store the email. + * @param {any} profile - The user profile from the OIDC provider + * @return {string | null} - The email address from the profile */ const safelyExtractEmail = (profile: any): string | null => { return ( @@ -121,6 +127,8 @@ const safelyExtractEmail = (profile: any): string | null => { * This helps differentiate users within the specific OIDC provider. * Note: This is incompatible with multiple providers. Ideally, users are identified by * OIDC ID (requires refactoring the database). + * @param {string} email - The email address to generate a username from + * @return {string} - The username generated from the email address */ const getUsername = (email: string): string => { return email ? email.split('@')[0] : ''; From c7ec2e3269f814be39543cdfdc8e5a17838ca17d Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Thu, 3 Jul 2025 20:29:41 +0900 Subject: [PATCH 16/17] chore: fix type errors and clean up --- src/config/types.ts | 2 +- .../processors/push-action/pullRemote.ts | 5 ++-- src/proxy/routes/index.ts | 2 +- src/service/passport/activeDirectory.ts | 1 - src/service/passport/jwtUtils.ts | 2 +- src/service/passport/ldaphelper.ts | 4 +-- src/service/passport/oidc.ts | 2 +- src/service/passport/types.ts | 26 +++++++++++++++++++ 8 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/config/types.ts b/src/config/types.ts index 6d8bb83a3..b2d66cc2f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -21,7 +21,7 @@ export interface UserSettings { urlShortener: string; contactEmail: string; csrfProtection: boolean; - domains: Record; + domains: Record; rateLimit: RateLimitConfig; } diff --git a/src/proxy/processors/push-action/pullRemote.ts b/src/proxy/processors/push-action/pullRemote.ts index 2f7c808a2..d62c2469e 100644 --- a/src/proxy/processors/push-action/pullRemote.ts +++ b/src/proxy/processors/push-action/pullRemote.ts @@ -1,11 +1,12 @@ import { Action, Step } from '../../actions'; import fs from 'fs'; -import git from 'isomorphic-git'; -import gitHttpClient from 'isomorphic-git/http/node'; const dir = './.remote'; const exec = async (req: any, action: Action): Promise => { + const git = await import('isomorphic-git'); + const gitHttpClient = await import('isomorphic-git/http/node/index.js'); + const step = new Step('pullRemote'); try { diff --git a/src/proxy/routes/index.ts b/src/proxy/routes/index.ts index 1e1cfff46..416a30568 100644 --- a/src/proxy/routes/index.ts +++ b/src/proxy/routes/index.ts @@ -48,7 +48,7 @@ const validGitRequest = (url: string, headers: any): boolean => { return false; } // https://www.git-scm.com/docs/http-protocol#_uploading_data - return agent.startsWith('git/') && accept.startsWith('application/x-git-') ; + return agent.startsWith('git/') && accept.startsWith('application/x-git-'); } return false; }; diff --git a/src/service/passport/activeDirectory.ts b/src/service/passport/activeDirectory.ts index 4cea2a564..cef397d00 100644 --- a/src/service/passport/activeDirectory.ts +++ b/src/service/passport/activeDirectory.ts @@ -59,7 +59,6 @@ export const configure = async (passport: PassportStatic): Promise => { // determine, via config, if we're using HTTP or AD directly - if (thirdpartyApiConfig?.ls?.userInADGroup) { + if ((thirdpartyApiConfig?.ls as any).userInADGroup !== '') { return isUserInAdGroupViaHttp(profile.username, domain, name); } else { return isUserInAdGroupViaAD(req, profile, ad, domain, name); @@ -46,7 +46,7 @@ const isUserInAdGroupViaHttp = ( domain: string, name: string ): Promise => { - const url = String(thirdpartyApiConfig?.ls?.userInADGroup) + const url = String((thirdpartyApiConfig?.ls as any).userInADGroup) .replace('', domain) .replace('', name) .replace('', id); diff --git a/src/service/passport/oidc.ts b/src/service/passport/oidc.ts index bb29b0638..151091e59 100644 --- a/src/service/passport/oidc.ts +++ b/src/service/passport/oidc.ts @@ -1,7 +1,7 @@ import * as db from '../../db'; import { PassportStatic } from 'passport'; import { getAuthMethods } from '../../config/index.ts'; -import { UserInfoResponse } from 'openid-client'; +import { UserInfoResponse } from './types'; export const type = 'openidconnect'; diff --git a/src/service/passport/types.ts b/src/service/passport/types.ts index e86d829f9..235e1b9ef 100644 --- a/src/service/passport/types.ts +++ b/src/service/passport/types.ts @@ -42,3 +42,29 @@ export type AD = { callback: (err: Error | null, isMember: boolean) => void ) => void; } + +/** + * The UserInfoResponse type from openid-client (to fix some type errors) + */ +export type UserInfoResponse = { + readonly sub: string; + readonly name?: string; + readonly given_name?: string; + readonly family_name?: string; + readonly middle_name?: string; + readonly nickname?: string; + readonly preferred_username?: string; + readonly profile?: string; + readonly picture?: string; + readonly website?: string; + readonly email?: string; + readonly email_verified?: boolean; + readonly gender?: string; + readonly birthdate?: string; + readonly zoneinfo?: string; + readonly locale?: string; + readonly phone_number?: string; + readonly updated_at?: number; + readonly address?: any; + readonly [claim: string]: any; +} From 6074d31e14ae827c5c2c3957205249213cdfd10a Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Thu, 3 Jul 2025 20:49:42 +0900 Subject: [PATCH 17/17] fix: failing test on empty username --- src/service/routes/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/service/routes/auth.ts b/src/service/routes/auth.ts index 89a529248..6edf44cbb 100644 --- a/src/service/routes/auth.ts +++ b/src/service/routes/auth.ts @@ -133,7 +133,7 @@ router.post('/gitAccount', async (req: Request, res: Response) => { ? req.body.id : req.body.username; - username = username.split('@')[0]; + username = username?.split('@')[0] ?? ''; if (!username) { res.status(400).send('Error: Missing username. Git account not updated').end();