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/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/package-lock.json b/package-lock.json index 5eb72a6f1..4807e3707 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,11 +66,17 @@ "@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", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", @@ -3688,6 +3694,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", @@ -3723,6 +3739,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", @@ -3730,6 +3756,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", @@ -3737,6 +3781,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", @@ -3750,6 +3804,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", @@ -3758,6 +3819,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", diff --git a/package.json b/package.json index f3da903db..de0d70e8a 100644 --- a/package.json +++ b/package.json @@ -89,11 +89,17 @@ "@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", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", diff --git a/src/config/index.ts b/src/config/index.ts index a3ea42136..81093f138 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/config/types.ts b/src/config/types.ts index 291de4081..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; } @@ -48,7 +48,39 @@ 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; + expectedAudience?: string; } export interface TempPasswordConfig { 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/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..778b7f960 --- /dev/null +++ b/src/service/index.ts @@ -0,0 +1,90 @@ +import express, { Express } 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 { 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; diff --git a/src/service/passport/activeDirectory.js b/src/service/passport/activeDirectory.ts similarity index 63% rename from src/service/passport/activeDirectory.js rename to src/service/passport/activeDirectory.ts index 8f681c823..cef397d00 100644 --- a/src/service/passport/activeDirectory.js +++ b/src/service/passport/activeDirectory.ts @@ -1,18 +1,33 @@ -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 = async (passport: PassportStatic): Promise => { + const authMethods = getAuthMethods(); const config = authMethods.find((method) => method.type.toLowerCase() === type); + + if (!config || !config.adConfig) { + 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'); + } + + // Handle legacy config + 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,7 +39,7 @@ 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; @@ -43,8 +58,7 @@ const configure = (passport) => { const message = `User it not a member of ${userGroup}`; return done(message, null); } - } catch (err) { - console.log('ad test (isUser): e', err); + } catch (err: any) { const message = `An error occurred while checking if the user is a member of the user group: ${err.message}`; return done(message, null); } @@ -54,8 +68,8 @@ const configure = (passport) => { try { isAdmin = await ldaphelper.isUserInAdGroup(req, profile, ad, domain, adminGroup); - } catch (err) { - const message = `An error occurred while checking if the user is a member of the admin group: ${err.message}`; + } catch (err: any) { + const message = `An error occurred while checking if the user is a member of the admin group: ${JSON.stringify(err)}`; console.error(message, err); // don't return an error for this case as you may still be a user } @@ -73,24 +87,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); }); - passport.type = "ActiveDirectory"; return passport; }; - -module.exports = { configure, 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..07852508a --- /dev/null +++ b/src/service/passport/index.ts @@ -0,0 +1,39 @@ +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; + type: string; +}; + +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/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..7effa59f4 --- /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} 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 + */ +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 00ba01f00..000000000 --- a/src/service/passport/ldaphelper.js +++ /dev/null @@ -1,51 +0,0 @@ -const thirdpartyApiConfig = require('../../config').getAPIs(); -const axios = require('axios'); - -const isUserInAdGroup = (req, profile, ad, domain, name) => { - // determine, via config, if we're using HTTP or AD directly - if (thirdpartyApiConfig?.ls?.userInADGroup) { - return isUserInAdGroupViaHttp(profile.username, domain, name); - } else { - return isUserInAdGroupViaAD(req, profile, ad, domain, name); - } -}; - -const isUserInAdGroupViaAD = (req, profile, ad, domain, name) => { - return new Promise((resolve, reject) => { - ad.isUserMemberOf(profile.username, name, function (err, isMember) { - if (err) { - const msg = 'ERROR isUserMemberOf: ' + JSON.stringify(err); - reject(msg); - } else { - console.log(profile.username + ' isMemberOf ' + name + ': ' + isMember); - resolve(isMember); - } - }); - }); -}; - -const isUserInAdGroupViaHttp = (id, domain, name) => { - const url = String(thirdpartyApiConfig.ls.userInADGroup) - .replace('', domain) - .replace('', name) - .replace('', id); - - const client = axios.create({ - responseType: 'json', - headers: { - 'content-type': 'application/json', - }, - }); - - 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..c34d1271c --- /dev/null +++ b/src/service/passport/ldaphelper.ts @@ -0,0 +1,66 @@ +import axios from 'axios'; +import { Request } from 'express'; + +import { getAPIs } from '../../config'; +import { AD } from './types'; + +const thirdpartyApiConfig = getAPIs(); + +export const isUserInAdGroup = ( + req: Request, + profile: { username: string }, + ad: AD, + domain: string, + name: string +): Promise => { + // determine, via config, if we're using HTTP or AD directly + if ((thirdpartyApiConfig?.ls as any).userInADGroup !== '') { + return isUserInAdGroupViaHttp(profile.username, domain, name); + } else { + return isUserInAdGroupViaAD(req, profile, ad, domain, name); + } +}; + +const isUserInAdGroupViaAD = ( + req: Request, + profile: { username: string }, + ad: AD, + domain: string, + name: string +): Promise => { + return new Promise((resolve, reject) => { + ad.isUserMemberOf(profile.username, name, (err, isMember) => { + if (err) { + const msg = `ERROR isUserMemberOf: ${JSON.stringify(err)}`; + reject(msg); + } else { + console.log(`${profile.username} isMemberOf ${name}: ${isMember}`); + resolve(isMember); + } + }); + }); +}; + +const isUserInAdGroupViaHttp = ( + id: string, + domain: string, + name: string +): Promise => { + const url = String((thirdpartyApiConfig?.ls as any).userInADGroup) + .replace('', domain) + .replace('', name) + .replace('', id); + + const client = axios.create({ + responseType: 'json', + headers: { + 'content-type': 'application/json', + }, + }); + + console.log(`checking if user is in group ${url}`); + return client + .get(url) + .then((res) => res.data) + .catch(() => false); +}; 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 }; diff --git a/src/service/passport/oidc.js b/src/service/passport/oidc.ts similarity index 52% rename from src/service/passport/oidc.js rename to src/service/passport/oidc.ts index 7e2aa5ee0..151091e59 100644 --- a/src/service/passport/oidc.js +++ b/src/service/passport/oidc.ts @@ -1,42 +1,48 @@ -const db = require('../../db'); +import * as db from '../../db'; +import { PassportStatic } from 'passport'; +import { getAuthMethods } from '../../config/index.ts'; +import { UserInfoResponse } from './types'; -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: (err: any, user?: any) => void) => { + 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 +50,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 +74,11 @@ 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 + * @param {UserInfoResponse} userInfo - The user info response from the OIDC provider + * @param {Function} done - The callback function to handle the user authentication + * @return {Promise} - A promise that resolves when the user authentication is complete */ -const handleUserAuthentication = async (userInfo, done) => { +const handleUserAuthentication = async (userInfo: UserInfoResponse, done: (err: any, user?: any) => void): Promise => { console.log('handleUserAuthentication called'); try { const user = await db.findUserByOIDC(userInfo.sub); @@ -88,7 +93,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 +112,24 @@ 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. + * @param {any} profile - The user profile from the OIDC provider + * @return {string | null} - The email address from the profile */ -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 + * @param {string} email - The email address to generate a username from + * @return {string} - The username generated from the email address */ -const getUsername = (email) => { +const getUsername = (email: string): string => { return email ? email.split('@')[0] : ''; }; - -module.exports = { configure, type }; diff --git a/src/service/passport/types.ts b/src/service/passport/types.ts new file mode 100644 index 000000000..235e1b9ef --- /dev/null +++ b/src/service/passport/types.ts @@ -0,0 +1,70 @@ +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>; + +export type AD = { + isUserMemberOf: ( + username: string, + groupName: string, + 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; +} diff --git a/src/service/routes/auth.js b/src/service/routes/auth.ts similarity index 61% rename from src/service/routes/auth.js rename to src/service/routes/auth.ts index 4a78c774c..6edf44cbb 100644 --- a/src/service/routes/auth.js +++ b/src/service/routes/auth.ts @@ -1,28 +1,27 @@ -const express = require('express'); -const router = new express.Router(); -const passport = require('../passport').getPassport(); -const { getAuthMethods } = require('../../config'); -const passportLocal = require('../passport/local'); -const passportAD = require('../passport/activeDirectory'); -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) => { +import express, { Request, Response, NextFunction } from 'express'; +import { getPassport, authStrategies } from '../passport'; +import { getAuthMethods } from '../../config'; + +import * as db from '../../db'; +import * as passportLocal from '../passport/local'; +import * as passportAD from '../passport/activeDirectory'; + +import { User } from '../../db/types'; +import { Authentication } from '../../config/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', - }, + login: { action: 'post', uri: '/api/auth/login' }, + profile: { action: 'get', uri: '/api/auth/profile' }, + logout: { action: 'post', uri: '/api/auth/logout' }, }); }); @@ -34,7 +33,7 @@ const appropriateLoginStrategies = [passportLocal.type, passportAD.type]; const getLoginStrategy = () => { // returns only enabled auth methods // returns at least one enabled auth method - const enabledAppropriateLoginStrategies = getAuthMethods().filter((am) => + const enabledAppropriateLoginStrategies = getAuthMethods().filter((am: Authentication) => appropriateLoginStrategies.includes(am.type.toLowerCase()), ); // for where no login strategies which work for /login are enabled @@ -50,7 +49,7 @@ const getLoginStrategy = () => { // TODO: if providing separate auth methods, inform the frontend so it has relevant UI elements and appropriate client-side behavior router.post( '/login', - (req, res, next) => { + (req: Request, res: Response, next: NextFunction) => { const authType = getLoginStrategy(); if (authType === null) { res.status(403).send('Username and Password based Login is not enabled at this time').end(); @@ -59,10 +58,10 @@ router.post( console.log('going to auth with', authType); return passport.authenticate(authType)(req, res, next); }, - async (req, res) => { + async (req: Request, res: Response) => { try { - const currentUser = { ...req.user }; - delete currentUser.password; + const currentUser = { ...req.user } as User; + delete (currentUser as any).password; console.log( `serivce.routes.auth.login: user logged in, username=${ currentUser.username @@ -72,7 +71,7 @@ router.post( message: 'success', user: currentUser, }); - } catch (e) { + } catch (e: any) { console.log(`service.routes.auth.login: Error logging user in ${JSON.stringify(e)}`); res.status(500).send('Failed to login').end(); return; @@ -80,10 +79,13 @@ router.post( }, ); -router.get('/oidc', passport.authenticate(authStrategies['openidconnect'].type)); +router.get( + '/oidc', + passport.authenticate(authStrategies['openidconnect'].type) +); -router.get('/oidc/callback', (req, res, next) => { - passport.authenticate(authStrategies['openidconnect'].type, (err, user, info) => { +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(); @@ -92,43 +94,46 @@ router.get('/oidc/callback', (req, res, next) => { 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.post('/logout', (req, res, next) => { - req.logout(req.user, (err) => { +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 }); }); - res.clearCookie('connect.sid'); - res.send({ isAuth: req.isAuthenticated(), user: req.user }); }); -router.get('/profile', async (req, res) => { +router.get('/profile', async (req: Request, res: Response) => { if (req.user) { - const userVal = await db.findUser(req.user.username); - delete userVal.password; + 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, res) => { +router.post('/gitAccount', async (req: Request, res: Response) => { if (req.user) { try { - let username = - req.body.username == null || req.body.username == 'undefined' + let username: string = + req.body.username == null || req.body.username === 'undefined' ? 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(); @@ -136,12 +141,12 @@ router.post('/gitAccount', async (req, res) => { } const user = await db.findUser(username); + console.log('Adding gitAccount:', req.body.gitAccount); - console.log('Adding gitAccount' + req.body.gitAccount); user.gitAccount = req.body.gitAccount; - db.updateUser(user); + await db.updateUser(user); res.status(200).end(); - } catch (e) { + } catch (e: any) { res .status(500) .send({ @@ -154,16 +159,19 @@ router.post('/gitAccount', async (req, res) => { } }); -router.get('/me', async (req, res) => { +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 && userVal.password) delete userVal.password; + + if (userVal?.password) delete userVal.password; res.send(userVal); } else { res.status(401).end(); } }); -module.exports = router; + +export default router; 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..2b760afb0 --- /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; 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..0178fd504 --- /dev/null +++ b/src/service/routes/push.ts @@ -0,0 +1,134 @@ +import express, { Request, Response } from 'express'; +import * as db from '../../db'; +import { AuthenticatedRequest } from './types'; + +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; 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; +} 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; 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; +} 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/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 () => { 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 },