diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index fdbc7ecb1..dd4c21bc5 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -21,6 +21,6 @@ jobs: with: comment-summary-in-pr: always fail-on-severity: high - allow-licenses: MIT, MIT-0, Apache-2.0, BSD-3-Clause, BSD-3-Clause-Clear, ISC, BSD-2-Clause, Unlicense, CC0-1.0, 0BSD, X11, MPL-2.0, MPL-1.0, MPL-1.1, MPL-2.0, Zlib + allow-licenses: MIT, MIT-0, Apache-2.0, BSD-3-Clause, BSD-3-Clause-Clear, ISC, BSD-2-Clause, Unlicense, CC0-1.0, 0BSD, X11, MPL-2.0, MPL-1.0, MPL-1.1, MPL-2.0, OFL-1.1, Zlib fail-on-scopes: development, runtime allow-dependencies-licenses: 'pkg:npm/caniuse-lite' diff --git a/config.schema.json b/config.schema.json index 4e9622ca0..d95162de9 100644 --- a/config.schema.json +++ b/config.schema.json @@ -79,6 +79,13 @@ } } }, + "apiAuthentication": { + "description": "List of authentication sources for API endpoints. May be empty, in which case all endpoints are public.", + "type": "array", + "items": { + "$ref": "#/definitions/authentication" + } + }, "tls": { "description": "TLS configuration for secure connections", "type": "object", diff --git a/cypress/e2e/autoApproved.cy.js b/cypress/e2e/autoApproved.cy.js index ae67f3ecd..65d9d65a1 100644 --- a/cypress/e2e/autoApproved.cy.js +++ b/cypress/e2e/autoApproved.cy.js @@ -2,6 +2,8 @@ import moment from 'moment'; describe('Auto-Approved Push Test', () => { beforeEach(() => { + cy.login('admin', 'admin'); + cy.intercept('GET', '/api/v1/push/123', { statusCode: 200, body: { @@ -45,7 +47,7 @@ describe('Auto-Approved Push Test', () => { }); it('should display auto-approved message and verify tooltip contains the expected timestamp', () => { - cy.visit('/admin/push/123'); + cy.visit('/dashboard/push/123'); cy.wait('@getPush'); diff --git a/cypress/e2e/login.cy.js b/cypress/e2e/login.cy.js index 590506f62..16da32f83 100644 --- a/cypress/e2e/login.cy.js +++ b/cypress/e2e/login.cy.js @@ -19,6 +19,18 @@ describe('Login page', () => { cy.get('[data-test="login"]').should('exist'); }); + it('should redirect to repo list on valid login', () => { + cy.intercept('GET', '**/api/auth/me').as('getUser'); + + cy.get('[data-test="username"]').type('admin'); + cy.get('[data-test="password"]').type('admin'); + cy.get('[data-test="login"]').click(); + + cy.wait('@getUser'); + + cy.url().should('include', '/dashboard/repo'); + }) + describe('OIDC login button', () => { it('should exist', () => { cy.get('[data-test="oidc-login"]').should('exist'); diff --git a/cypress/e2e/repo.cy.js b/cypress/e2e/repo.cy.js index 32c7d1cab..411397128 100644 --- a/cypress/e2e/repo.cy.js +++ b/cypress/e2e/repo.cy.js @@ -1,6 +1,8 @@ describe('Repo', () => { beforeEach(() => { - cy.visit('/admin/repo'); + cy.login('admin', 'admin'); + + cy.visit('/dashboard/repo'); // prevent failures on 404 request and uncaught promises cy.on('uncaught:exception', () => false); @@ -18,7 +20,7 @@ describe('Repo', () => { cy // find the entry for finos/test-repo - .get('a[href="/admin/repo/test-repo"]') + .get('a[href="/dashboard/repo/test-repo"]') // take it's parent row .closest('tr') // find the nearby span containing Code we can click to open the tooltip diff --git a/cypress/support/commands.js b/cypress/support/commands.js index aa3b052c2..751eabdfa 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -29,9 +29,13 @@ Cypress.Commands.add('login', (username, password) => { cy.session([username, password], () => { cy.visit('/login'); + cy.intercept('GET', '**/api/auth/me').as('getUser'); + cy.get('[data-test=username]').type(username); cy.get('[data-test=password]').type(password); cy.get('[data-test=login]').click(); - cy.url().should('contain', '/admin/profile'); + + cy.wait('@getUser'); + cy.url().should('include', '/dashboard/repo'); }); }); diff --git a/package-lock.json b/package-lock.json index 3052eaddc..45cd90bae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,13 +32,15 @@ "history": "5.3.0", "isomorphic-git": "^1.27.1", "jsonschema": "^1.4.1", + "jsonwebtoken": "^9.0.2", + "jwk-to-pem": "^2.0.7", "load-plugin": "^6.0.0", "lodash": "^4.17.21", "lusca": "^1.7.0", "moment": "^2.29.4", "mongodb": "^5.0.0", "nodemailer": "^6.6.1", - "openid-client": "^6.2.0", + "openid-client": "^6.3.1", "parse-diff": "^0.11.1", "passport": "^0.7.0", "passport-activedirectory": "^1.0.4", @@ -4819,6 +4821,12 @@ "node": ">=8" } }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", @@ -4899,6 +4907,12 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -6111,6 +6125,15 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6123,6 +6146,21 @@ "dev": true, "license": "ISC" }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -7943,6 +7981,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasha": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", @@ -8015,6 +8063,17 @@ "@babel/runtime": "^7.7.6" } }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/hogan.js": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/hogan.js/-/hogan.js-3.0.2.tgz", @@ -9078,9 +9137,9 @@ } }, "node_modules/jose": { - "version": "5.9.6", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", - "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -9227,6 +9286,40 @@ "node": "*" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", @@ -9377,6 +9470,38 @@ "dev": true, "license": "MIT" }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwk-to-pem": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/jwk-to-pem/-/jwk-to-pem-2.0.7.tgz", + "integrity": "sha512-cSVphrmWr6reVchuKQZdfSs4U9c5Y4hwZggPoz6cbVnTpAVgGRpEuQng86IyqLeGZlhTh+c4MAreB6KbdQDKHQ==", + "license": "Apache-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "elliptic": "^6.6.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9603,11 +9728,40 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" }, "node_modules/lodash.kebabcase": { "version": "4.1.1", @@ -9632,8 +9786,7 @@ "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" }, "node_modules/lodash.snakecase": { "version": "4.1.1", @@ -9966,6 +10119,12 @@ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -10604,9 +10763,9 @@ } }, "node_modules/oauth4webapi": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.2.0.tgz", - "integrity": "sha512-2sYwQXuuzGKOHpnM7QL9BssDrly5gKCgJKTyrhmFIHzJRj0fFsr6GVJEdesmrX6NpMg2u63V4hJwRsZE6PUSSA==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.3.0.tgz", + "integrity": "sha512-ZlozhPlFfobzh3hB72gnBFLjXpugl/dljz1fJSRdqaV2r3D5dmi5lg2QWI0LmUYuazmE+b5exsloEv6toUtw9g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -10759,13 +10918,13 @@ } }, "node_modules/openid-client": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.2.0.tgz", - "integrity": "sha512-pvLVkLcRWNU7YuKKTto376rgL//+rn3ca0XRqsrQVN30lVlpXBPHhSLcGoM/hPbux5p+Ha4tdoz96eEYpyguOQ==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.3.1.tgz", + "integrity": "sha512-l+uRCCM+KaGKQmCWjrjlFHXa1husuc72OPCCyGB7VjHeEMVldfwsn4Pfb/5Xk51FRqbRakMbwfIUPiAMQDHaSw==", "license": "MIT", "dependencies": { - "jose": "^5.9.6", - "oauth4webapi": "^3.2.0" + "jose": "^5.10.0", + "oauth4webapi": "^3.3.0" }, "funding": { "url": "https://github.com/sponsors/panva" diff --git a/package.json b/package.json index 757dfbd92..cf302235f 100644 --- a/package.json +++ b/package.json @@ -54,13 +54,15 @@ "history": "5.3.0", "isomorphic-git": "^1.27.1", "jsonschema": "^1.4.1", + "jsonwebtoken": "^9.0.2", + "jwk-to-pem": "^2.0.7", "load-plugin": "^6.0.0", "lodash": "^4.17.21", "lusca": "^1.7.0", "moment": "^2.29.4", "mongodb": "^5.0.0", "nodemailer": "^6.6.1", - "openid-client": "^6.2.0", + "openid-client": "^6.4.2", "parse-diff": "^0.11.1", "passport": "^0.7.0", "passport-activedirectory": "^1.0.4", diff --git a/proxy.config.json b/proxy.config.json index fdb32a0d0..5f3e73cfb 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -49,6 +49,17 @@ "baseDN": "", "searchBase": "" } + }, + { + "type": "openidconnect", + "enabled": false, + "oidcConfig": { + "issuer": "", + "clientID": "", + "clientSecret": "", + "callbackURL": "", + "scope": "" + } } ], "api": { @@ -98,6 +109,22 @@ "contactEmail": "", "csrfProtection": true, "plugins": [], + "apiAuthentication": [ + { + "type": "jwt", + "enabled": false, + "jwtConfig": { + "authorityURL": "", + "clientID": "", + "expectedAudience": "", + "roleMapping": { + "admin": { + "": "" + } + } + } + } + ], "tls": { "enabled": true, "key": "certs/key.pem", diff --git a/src/config/index.ts b/src/config/index.ts index 782c75564..633cff9e6 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -14,9 +14,11 @@ let _userSettings: UserSettings | null = null; if (existsSync(configFile)) { _userSettings = JSON.parse(readFileSync(configFile, 'utf-8')); } + let _authorisedList: AuthorisedRepo[] = defaultSettings.authorisedList; let _database: Database[] = defaultSettings.sink; let _authentication: Authentication[] = defaultSettings.authentication; +let _apiAuthentication: Authentication[] = defaultSettings.apiAuthentication; let _tempPassword: TempPasswordConfig = defaultSettings.tempPassword; let _proxyUrl = defaultSettings.proxyUrl; let _api: Record = defaultSettings.api; @@ -78,27 +80,47 @@ export const getDatabase = () => { throw Error('No database cofigured!'); }; -// Gets the configured authentication method, defaults to local -export const getAuthentication = () => { +/** + * Get the list of enabled authentication methods + * @return {Authentication[]} List of enabled authentication methods + */ +export const getAuthMethods = (): Authentication[] => { if (_userSettings !== null && _userSettings.authentication) { _authentication = _userSettings.authentication; } - for (const ix in _authentication) { - if (!ix) continue; - const auth = _authentication[ix]; - if (auth.enabled) { - return auth; - } + + const enabledAuthMethods = _authentication.filter(auth => auth.enabled); + + if (enabledAuthMethods.length === 0) { + throw new Error("No authentication method enabled."); + } + + return enabledAuthMethods; +}; + +/** + * Get the list of enabled authentication methods for API endpoints + * @return {Authentication[]} List of enabled authentication methods + */ +export const getAPIAuthMethods = (): Authentication[] => { + if (_userSettings !== null && _userSettings.apiAuthentication) { + _apiAuthentication = _userSettings.apiAuthentication; + } + + const enabledAuthMethods = _apiAuthentication.filter(auth => auth.enabled); + + if (enabledAuthMethods.length === 0) { + console.log("Warning: No authentication method enabled for API endpoints."); } - throw Error('No authentication cofigured!'); + return enabledAuthMethods; }; // Log configuration to console export const logConfiguration = () => { console.log(`authorisedList = ${JSON.stringify(getAuthorisedList())}`); console.log(`data sink = ${JSON.stringify(getDatabase())}`); - console.log(`authentication = ${JSON.stringify(getAuthentication())}`); + console.log(`authentication = ${JSON.stringify(getAuthMethods())}`); }; export const getAPIs = () => { diff --git a/src/config/types.ts b/src/config/types.ts index 30428f232..fde529de4 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -2,6 +2,7 @@ export interface UserSettings { authorisedList: AuthorisedRepo[]; sink: Database[]; authentication: Authentication[]; + apiAuthentication: Authentication[]; tempPassword?: TempPasswordConfig; proxyUrl: string; api: Record; diff --git a/src/index.jsx b/src/index.jsx index 4aca4983b..04505ff5f 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -2,21 +2,28 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { createBrowserHistory } from 'history'; import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom'; +import { AuthProvider } from './ui/auth/AuthProvider'; // core components -import Admin from './ui/layouts/Admin'; +import Dashboard from './ui/layouts/Dashboard'; import Login from './ui/views/Login/Login'; import './ui/assets/css/material-dashboard-react.css'; +import NotAuthorized from './ui/views/Extras/NotAuthorized'; +import NotFound from './ui/views/Extras/NotFound'; const hist = createBrowserHistory(); ReactDOM.render( - - - } /> - } /> - } /> - - , + + + + } /> + } /> + } /> + } /> + } /> + + + , document.getElementById('root'), ); diff --git a/src/proxy/processors/push-action/blockForAuth.ts b/src/proxy/processors/push-action/blockForAuth.ts index bbef7725d..4fde08e0d 100644 --- a/src/proxy/processors/push-action/blockForAuth.ts +++ b/src/proxy/processors/push-action/blockForAuth.ts @@ -9,7 +9,7 @@ const exec = async (req: any, action: Action) => { '\n\n\n' + `\x1B[32mGitProxy has received your push ✅\x1B[0m\n\n` + '🔗 Shareable Link\n\n' + - `\x1B[34m${url}/admin/push/${action.id}\x1B[0m` + + `\x1B[34m${url}/dashboard/push/${action.id}\x1B[0m` + '\n\n\n'; step.setAsyncBlock(message); diff --git a/src/routes.jsx b/src/routes.jsx index 526b452aa..a1204b735 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -16,6 +16,8 @@ */ +import React from 'react'; +import PrivateRoute from './ui/components/PrivateRoute/PrivateRoute'; import Person from '@material-ui/icons/Person'; import OpenPushRequests from './ui/views/OpenPushRequests/OpenPushRequests'; import PushDetails from './ui/views/PushDetails/PushDetails'; @@ -33,58 +35,58 @@ const dashboardRoutes = [ path: '/repo', name: 'Repositories', icon: RepoIcon, - component: RepoList, - layout: '/admin', + component: (props) => , + layout: '/dashboard', visible: true, }, + { + path: '/repo/:id', + name: 'Repo Details', + icon: Person, + component: (props) => , + layout: '/dashboard', + visible: false, + }, { path: '/push', name: 'Dashboard', icon: Dashboard, - component: OpenPushRequests, - layout: '/admin', + component: (props) => , + layout: '/dashboard', visible: true, }, { path: '/push/:id', name: 'Open Push Requests', icon: Person, - component: PushDetails, - layout: '/admin', + component: (props) => , + layout: '/dashboard', visible: false, }, { path: '/profile', name: 'My Account', icon: AccountCircle, - component: User, - layout: '/admin', + component: (props) => , + layout: '/dashboard', visible: true, }, { - path: '/user/:id', - name: 'User', - icon: Person, - component: User, - layout: '/admin', - visible: false, + path: '/admin/user', + name: 'Users', + icon: Group, + component: (props) => , + layout: '/dashboard', + visible: true, }, { - path: '/repo/:id', - name: 'Repo Details', + path: '/admin/user/:id', + name: 'User', icon: Person, - component: RepoDetails, - layout: '/admin', + component: (props) => , + layout: '/dashboard', visible: false, }, - { - path: '/user', - name: 'Users', - icon: Group, - component: UserList, - layout: '/admin', - visible: true, - }, ]; export default dashboardRoutes; diff --git a/src/service/passport/activeDirectory.js b/src/service/passport/activeDirectory.js index 466f57b16..372868133 100644 --- a/src/service/passport/activeDirectory.js +++ b/src/service/passport/activeDirectory.js @@ -1,16 +1,19 @@ -const configure = () => { - const passport = require('passport'); - const ActiveDirectoryStrategy = require('passport-activedirectory'); - const config = require('../../config').getAuthentication(); - const adConfig = config.adConfig; +const ActiveDirectoryStrategy = require('passport-activedirectory'); +const ldaphelper = require('./ldaphelper'); + +const configure = (passport) => { const db = require('../../db'); - const userGroup = config.userGroup; - const adminGroup = config.adminGroup; - const domain = config.domain; + + // 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(); + const config = authMethods.find((method) => method.type.toLowerCase() === "activeDirectory"); + const adConfig = config.adConfig; + + const { userGroup, adminGroup, domain } = config; console.log(`AD User Group: ${userGroup}, AD Admin Group: ${adminGroup}`); - const ldaphelper = require('./ldaphelper'); passport.use( new ActiveDirectoryStrategy( { @@ -19,42 +22,47 @@ const configure = () => { ldap: adConfig, }, async function (req, profile, ad, done) { - profile.username = profile._json.sAMAccountName.toLowerCase(); - profile.email = profile._json.mail; - profile.id = profile.username; - req.user = profile; - - console.log( - `passport.activeDirectory: resolved login ${ - profile._json.userPrincipalName - }, profile=${JSON.stringify(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}`; - return done(message, null); - } + try { + profile.username = profile._json.sAMAccountName?.toLowerCase(); + profile.email = profile._json.mail; + profile.id = profile.username; + req.user = profile; - // Now check if the user is an admin - const isAdmin = await ldaphelper.isUserInAdGroup(profile.username, domain, adminGroup); + console.log( + `passport.activeDirectory: resolved login ${ + profile._json.userPrincipalName + }, profile=${JSON.stringify(profile)}`, + ); + // First check to see if the user is in the usergroups + const isUser = await ldaphelper.isUserInAdGroup(profile.username, domain, userGroup); - profile.admin = isAdmin; - console.log(`passport.activeDirectory: ${profile.username} admin=${isAdmin}`); + if (!isUser) { + const message = `User it not a member of ${userGroup}`; + return done(message, null); + } - const user = { - username: profile.username, - admin: isAdmin, - email: profile._json.mail, - displayName: profile.displayName, - title: profile._json.title, - }; + // Now check if the user is an admin + const isAdmin = await ldaphelper.isUserInAdGroup(profile.username, domain, adminGroup); - await db.updateUser(user); + profile.admin = isAdmin; + console.log(`passport.activeDirectory: ${profile.username} admin=${isAdmin}`); - return done(null, user); - }, + const user = { + username: profile.username, + admin: isAdmin, + email: profile._json.mail, + displayName: profile.displayName, + title: profile._json.title, + }; + + await db.updateUser(user); + + return done(null, user); + } catch (err) { + console.log(`Error authenticating AD user: ${err.message}`); + return done(err, null); + } + } ), ); @@ -69,4 +77,4 @@ const configure = () => { return passport; }; -module.exports.configure = configure; +module.exports = { configure }; diff --git a/src/service/passport/index.js b/src/service/passport/index.js index a2d7931ef..533577d13 100644 --- a/src/service/passport/index.js +++ b/src/service/passport/index.js @@ -1,33 +1,38 @@ +const passport = require("passport"); const local = require('./local'); const activeDirectory = require('./activeDirectory'); const oidc = require('./oidc'); const config = require('../../config'); -const authenticationConfig = config.getAuthentication(); -let _passport; + +// 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 () => { - const type = authenticationConfig.type.toLowerCase(); - - switch (type) { - case 'activedirectory': - _passport = await activeDirectory.configure(); - break; - case 'local': - _passport = await local.configure(); - break; - case 'openidconnect': - _passport = await oidc.configure(); - break; - default: - throw Error(`uknown authentication type ${type}`); + passport.initialize(); + + const authMethods = config.getAuthMethods(); + console.log(`authMethods: ${JSON.stringify(authMethods)}`); + + for (const auth of authMethods) { + const strategy = authStrategies[auth.type.toLowerCase()]; + if (strategy && typeof strategy.configure === "function") { + await strategy.configure(passport); + } + console.log(`strategy type for ${auth.type}: ${strategy.type}`); } - if (!_passport.type) { - _passport.type = type; + + if (authMethods.some(auth => auth.type.toLowerCase() === "local")) { + await local.createDefaultAdmin(); } - return _passport; -}; -module.exports.configure = configure; -module.exports.getPassport = () => { - return _passport; + return passport; }; + +const getPassport = () => passport; + +module.exports = { authStrategies, configure, getPassport }; diff --git a/src/service/passport/jwtAuthHandler.js b/src/service/passport/jwtAuthHandler.js new file mode 100644 index 000000000..32c81304d --- /dev/null +++ b/src/service/passport/jwtAuthHandler.js @@ -0,0 +1,53 @@ +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/jwtUtils.js b/src/service/passport/jwtUtils.js new file mode 100644 index 000000000..45bda4cc9 --- /dev/null +++ b/src/service/passport/jwtUtils.js @@ -0,0 +1,93 @@ +const axios = require("axios"); +const jwt = require("jsonwebtoken"); +const jwkToPem = require("jwk-to-pem"); + +/** + * Obtain the JSON Web Key Set (JWKS) from the OIDC authority. + * @param {string} authorityUrl the OIDC authority URL. e.g. https://login.microsoftonline.com/{tenantId} + * @return {Promise} the JWKS keys + */ +async function getJwks(authorityUrl) { + try { + const { data } = await axios.get(`${authorityUrl}/.well-known/openid-configuration`); + const jwksUri = data.jwks_uri; + + const { data: jwks } = await axios.get(jwksUri); + return jwks.keys; + } catch (error) { + console.error("Error fetching JWKS:", error); + throw new Error("Failed to fetch JWKS"); + } +} + +/** + * Validate a JWT token using the OIDC configuration. + * @param {*} token the JWT token + * @param {*} authorityUrl the OIDC authority URL + * @param {*} clientID the OIDC client ID + * @param {*} expectedAudience the expected audience for the token + * @param {*} getJwksInject the getJwks function to use (for dependency injection). Defaults to the built-in getJwks function. + * @return {Promise} the verified payload or an error + */ +async function validateJwt(token, authorityUrl, clientID, expectedAudience, getJwksInject = getJwks) { + try { + const jwks = await getJwksInject(authorityUrl); + + const decodedHeader = await jwt.decode(token, { complete: true }); + if (!decodedHeader || !decodedHeader.header || !decodedHeader.header.kid) { + throw new Error("Invalid JWT: Missing key ID (kid)"); + } + + const { kid } = decodedHeader.header; + const jwk = jwks.find((key) => key.kid === kid); + if (!jwk) { + throw new Error("No matching key found in JWKS"); + } + + const pubKey = jwkToPem(jwk); + + const verifiedPayload = jwt.verify(token, pubKey, { + algorithms: ["RS256"], + issuer: authorityUrl, + audience: expectedAudience, + }); + + if (verifiedPayload.azp !== clientID) { + throw new Error("JWT client ID does not match"); + } + + return { verifiedPayload }; + } catch (error) { + const errorMessage = `JWT validation failed: ${error.message}\n`; + console.error(errorMessage); + return { error: errorMessage }; + } +} + +/** + * Assign roles to the user based on the role mappings provided in the jwtConfig. + * + * If no role mapping is provided, the user will not have any roles assigned (i.e. user.admin = false). + * @param {*} roleMapping the role mapping configuration + * @param {*} payload the JWT payload + * @param {*} user the req.user object to assign roles to + */ +function assignRoles(roleMapping, payload, user) { + if (roleMapping) { + for (const role of Object.keys(roleMapping)) { + const claimValuePair = roleMapping[role]; + const claim = Object.keys(claimValuePair)[0]; + const value = claimValuePair[claim]; + + if (payload[claim] && payload[claim] === value) { + user[role] = true; + } + } + } +} + +module.exports = { + getJwks, + validateJwt, + assignRoles, +}; diff --git a/src/service/passport/local.js b/src/service/passport/local.js index c75676577..579d47234 100644 --- a/src/service/passport/local.js +++ b/src/service/passport/local.js @@ -1,53 +1,54 @@ -const bcrypt = require('bcryptjs'); -/* eslint-disable max-len */ -const configure = async () => { - const passport = require('passport'); - const Strategy = require('passport-local').Strategy; - const db = require('../../db'); +const bcrypt = require("bcryptjs"); +const LocalStrategy = require("passport-local").Strategy; +const db = require("../../db"); +const type = "local"; + +const configure = async (passport) => { passport.use( - new Strategy((username, password, cb) => { - db.findUser(username) - .then(async (user) => { - if (!user) { - return cb(null, false); - } - - const passwordCorrect = await bcrypt.compare(password, user.password); - - if (!passwordCorrect) { - return cb(null, false); - } - return cb(null, user); - }) - .catch((err) => { - return cb(err); - }); - }), + new LocalStrategy(async (username, password, done) => { + try { + const user = await db.findUser(username); + if (!user) { + return done(null, false, { message: "Incorrect username." }); + } + + const passwordCorrect = await bcrypt.compare(password, user.password); + if (!passwordCorrect) { + return done(null, false, { message: "Incorrect password." }); + } + + return done(null, user); + } catch (err) { + return done(err); + } + }) ); - passport.serializeUser(function (user, cb) { - cb(null, user.username); + passport.serializeUser((user, done) => { + done(null, user.username); }); - passport.deserializeUser(function (username, cb) { - db.findUser(username) - .then((user) => { - cb(null, user); - }) - .catch((err) => { - db(err, null); - }); + passport.deserializeUser(async (username, done) => { + try { + const user = await db.findUser(username); + done(null, user); + } catch (err) { + done(err, null); + } }); - const admin = await db.findUser('admin'); + return passport; +}; +/** + * Create the default admin user if it doesn't exist + */ +const createDefaultAdmin = async () => { + const admin = await db.findUser("admin"); if (!admin) { - await db.createUser('admin', 'admin', 'admin@place.com', 'none', true); + await db.createUser("admin", "admin", "admin@place.com", "none", true); } - - passport.type = 'local'; - return passport; }; -module.exports.configure = configure; +module.exports = { configure, createDefaultAdmin, type }; diff --git a/src/service/passport/oidc.js b/src/service/passport/oidc.js index 904faff04..2e2cd41d3 100644 --- a/src/service/passport/oidc.js +++ b/src/service/passport/oidc.js @@ -1,12 +1,10 @@ -const passport = require('passport'); const db = require('../../db'); -const configure = async () => { - // Temp fix for ERR_REQUIRE_ESM, will be changed when we refactor to ESM - const { discovery, fetchUserInfo } = await import('openid-client'); - const { Strategy } = await import('openid-client/passport'); - const config = require('../../config').getAuthentication(); - const { oidcConfig } = config; +let type; + +const configure = async (passport) => { + const authMethods = require('../../config').getAuthMethods(); + const oidcConfig = authMethods.find((method) => method.type.toLowerCase() === "openidconnect")?.oidcConfig; const { issuer, clientID, clientSecret, callbackURL, scope } = oidcConfig; if (!oidcConfig || !oidcConfig.issuer) { @@ -14,18 +12,20 @@ const configure = async () => { } const server = new URL(issuer); + const openIdClient = await import('openid-client'); + const { Strategy } = await import('openid-client/passport'); try { - const config = await discovery(server, clientID, clientSecret); + const config = await openIdClient.discovery(server, clientID, clientSecret); 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); + const userInfo = await openIdClient.fetchUserInfo(config, tokenSet.access_token, expectedSub); handleUserAuthentication(userInfo, done); }); - + // currentUrl must be overridden to match the callback URL strategy.currentUrl = function (request) { const callbackUrl = new URL(callbackURL); @@ -49,17 +49,15 @@ const configure = async () => { done(err); } }) - passport.type = server.host; + + type = server.host; return passport; } catch (error) { console.error('OIDC configuration failed:', error); throw error; } -} - - -module.exports.configure = configure; +}; /** * Handles user authentication with OIDC. @@ -112,3 +110,10 @@ const safelyExtractEmail = (profile) => { const getUsername = (email) => { return email ? email.split('@')[0] : ''; }; + +module.exports = { + configure, + get type() { + return type; + } +}; diff --git a/src/service/routes/auth.js b/src/service/routes/auth.js index 92cd82e39..aaf2efa26 100644 --- a/src/service/routes/auth.js +++ b/src/service/routes/auth.js @@ -1,8 +1,8 @@ const express = require('express'); const router = new express.Router(); const passport = require('../passport').getPassport(); +const authStrategies = require('../passport').authStrategies; const db = require('../../db'); -const passportType = passport.type; const { GIT_PROXY_UI_HOST: uiHost = 'http://localhost', GIT_PROXY_UI_PORT: uiPort = 3000 } = process.env; router.get('/', (req, res) => { @@ -22,7 +22,7 @@ router.get('/', (req, res) => { }); }); -router.post('/login', passport.authenticate(passportType), async (req, res) => { +router.post('/login', passport.authenticate(authStrategies['local'].type), async (req, res) => { try { const currentUser = { ...req.user }; delete currentUser.password; @@ -42,10 +42,10 @@ router.post('/login', passport.authenticate(passportType), async (req, res) => { } }); -router.get('/oidc', passport.authenticate(passportType)); +router.get('/oidc', passport.authenticate(authStrategies['openidconnect'].type)); router.get('/oidc/callback', (req, res, next) => { - passport.authenticate(passportType, (err, user, info) => { + passport.authenticate(authStrategies['openidconnect'].type, (err, user, info) => { if (err) { console.error('Authentication error:', err); return res.status(401).end(); @@ -60,7 +60,7 @@ router.get('/oidc/callback', (req, res, next) => { return res.status(401).end(); } console.log('Logged in successfully. User:', user); - return res.redirect(`${uiHost}:${uiPort}/admin/profile`); + return res.redirect(`${uiHost}:${uiPort}/dashboard/profile`); }); })(req, res, next); }); @@ -135,7 +135,7 @@ router.post('/gitAccount', async (req, res) => { } }); -router.get('/userLoggedIn', async (req, res) => { +router.get('/me', async (req, res) => { if (req.user) { const user = JSON.parse(JSON.stringify(req.user)); if (user && user.password) delete user.password; diff --git a/src/service/routes/index.js b/src/service/routes/index.js index a7529a4ac..45b276c17 100644 --- a/src/service/routes/index.js +++ b/src/service/routes/index.js @@ -6,14 +6,15 @@ 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', push); -router.use('/api/v1/repo', repo); -router.use('/api/v1/user', users); +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/ui/assets/jss/material-dashboard-react/layouts/adminStyle.js b/src/ui/assets/jss/material-dashboard-react/layouts/dashboardStyle.js similarity index 100% rename from src/ui/assets/jss/material-dashboard-react/layouts/adminStyle.js rename to src/ui/assets/jss/material-dashboard-react/layouts/dashboardStyle.js diff --git a/src/ui/auth/AuthProvider.tsx b/src/ui/auth/AuthProvider.tsx new file mode 100644 index 000000000..f7ab4b2fc --- /dev/null +++ b/src/ui/auth/AuthProvider.tsx @@ -0,0 +1,46 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { getUserInfo } from '../services/auth'; + +// Interface for when we convert to TypeScript +// interface AuthContextType { +// user: any; +// setUser: (user: any) => void; +// refreshUser: () => Promise; +// isLoading: boolean; +// } + +const AuthContext = createContext(undefined); + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const refreshUser = async () => { + try { + const data = await getUserInfo(); + setUser(data); + } catch (error) { + setUser(null); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + refreshUser(); + }, []); + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/src/ui/components/Navbars/AdminNavbarLinks.jsx b/src/ui/components/Navbars/DashboardNavbarLinks.jsx similarity index 97% rename from src/ui/components/Navbars/AdminNavbarLinks.jsx rename to src/ui/components/Navbars/DashboardNavbarLinks.jsx index 1821f52c1..a3e6e4177 100644 --- a/src/ui/components/Navbars/AdminNavbarLinks.jsx +++ b/src/ui/components/Navbars/DashboardNavbarLinks.jsx @@ -19,7 +19,7 @@ import { getCookie } from '../../utils'; const useStyles = makeStyles(styles); -export default function AdminNavbarLinks() { +export default function DashboardNavbarLinks() { const classes = useStyles(); const navigate = useNavigate(); const [openProfile, setOpenProfile] = React.useState(null); @@ -44,7 +44,7 @@ export default function AdminNavbarLinks() { }; const showProfile = () => { - navigate('/admin/profile', { replace: true }); + navigate('/dashboard/profile', { replace: true }); }; const logout = () => { diff --git a/src/ui/components/Navbars/Navbar.jsx b/src/ui/components/Navbars/Navbar.jsx index e3925bc8f..44fd6bd08 100644 --- a/src/ui/components/Navbars/Navbar.jsx +++ b/src/ui/components/Navbars/Navbar.jsx @@ -7,7 +7,7 @@ import Toolbar from '@material-ui/core/Toolbar'; import IconButton from '@material-ui/core/IconButton'; import Hidden from '@material-ui/core/Hidden'; import Menu from '@material-ui/icons/Menu'; -import AdminNavbarLinks from './AdminNavbarLinks'; +import DashboardNavbarLinks from './DashboardNavbarLinks'; import styles from '../../assets/jss/material-dashboard-react/components/headerStyle'; const useStyles = makeStyles(styles); @@ -42,7 +42,7 @@ export default function Header(props) { - + diff --git a/src/ui/components/PrivateRoute/PrivateRoute.tsx b/src/ui/components/PrivateRoute/PrivateRoute.tsx new file mode 100644 index 000000000..d010a7d86 --- /dev/null +++ b/src/ui/components/PrivateRoute/PrivateRoute.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../../auth/AuthProvider'; + +const PrivateRoute = ({ component: Component, adminOnly = false }) => { + const { user, isLoading } = useAuth(); + + if (isLoading) { + return
Loading...
; // TODO: Add loading spinner + } + + if (!user) { + return ; + } + + if (adminOnly && !user.admin) { + return ; + } + + return ; +}; + +export default PrivateRoute; diff --git a/src/ui/components/Sidebar/Sidebar.jsx b/src/ui/components/Sidebar/Sidebar.jsx index 174e31a4e..2ebef0fa5 100644 --- a/src/ui/components/Sidebar/Sidebar.jsx +++ b/src/ui/components/Sidebar/Sidebar.jsx @@ -73,7 +73,7 @@ export default function Sidebar(props) { ); const brand = (
- +
{routes.map((prop, key) => { - if (prop.layout === '/admin') { + if (prop.layout === '/dashboard') { return } key={key} />; } return null; })} - } /> + } /> ); const useStyles = makeStyles(styles); -export default function Admin({ ...rest }) { +export default function Dashboard({ ...rest }) { // styles const classes = useStyles(); // ref to help us initialize PerfectScrollbar on windows devices @@ -43,7 +43,7 @@ export default function Admin({ ...rest }) { setMobileOpen(!mobileOpen); }; const getRoute = () => { - return window.location.pathname !== '/admin/maps'; + return window.location.pathname !== '/dashboard/maps'; }; const resizeFunction = () => { if (window.innerWidth >= 960) { diff --git a/src/ui/services/auth.js b/src/ui/services/auth.js new file mode 100644 index 000000000..e1155e9f5 --- /dev/null +++ b/src/ui/services/auth.js @@ -0,0 +1,22 @@ +const baseUrl = import.meta.env.VITE_API_URI + ? `${import.meta.env.VITE_API_URI}` + : `${location.origin}`; + +/** + * Gets the current user's information + * @return {Promise} The user's information + */ +export const getUserInfo = async () => { + try { + const response = await fetch(`${baseUrl}/api/auth/me`, { + credentials: 'include', // Sends cookies + }); + + if (!response.ok) throw new Error(`Failed to fetch user info: ${response.statusText}`); + + return await response.json(); + } catch (error) { + console.error('Error fetching user info:', error); + return null; + } +}; diff --git a/src/ui/services/user.js b/src/ui/services/user.js index 04a2fdccb..cab1dc3ea 100644 --- a/src/ui/services/user.js +++ b/src/ui/services/user.js @@ -77,7 +77,7 @@ const updateUser = async (data) => { }; const getUserLoggedIn = async (setIsLoading, setIsAdmin, setIsError, setAuth) => { - const url = new URL(`${baseUrl}/api/auth/userLoggedIn`); + const url = new URL(`${baseUrl}/api/auth/me`); await axios(url.toString(), { withCredentials: true }) .then((response) => { diff --git a/src/ui/views/Extras/NotAuthorized.jsx b/src/ui/views/Extras/NotAuthorized.jsx new file mode 100644 index 000000000..f08c478b1 --- /dev/null +++ b/src/ui/views/Extras/NotAuthorized.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import Card from '../../components/Card/Card'; +import CardBody from '../../components/Card/CardBody'; +import GridContainer from '../../components/Grid/GridContainer'; +import GridItem from '../../components/Grid/GridItem'; +import { Button } from '@material-ui/core'; +import LockIcon from '@material-ui/icons/Lock'; + +const NotAuthorized = () => { + const navigate = useNavigate(); + + return ( + + + + + +

403 - Not Authorized

+

+ You do not have permission to access this page. Contact your administrator for more + information, or try logging in with a different account. +

+ +
+
+
+
+ ); +}; + +export default NotAuthorized; diff --git a/src/ui/views/Extras/NotFound.jsx b/src/ui/views/Extras/NotFound.jsx new file mode 100644 index 000000000..d548200de --- /dev/null +++ b/src/ui/views/Extras/NotFound.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import Card from '../../components/Card/Card'; +import CardBody from '../../components/Card/CardBody'; +import GridContainer from '../../components/Grid/GridContainer'; +import GridItem from '../../components/Grid/GridItem'; +import { Button } from '@material-ui/core'; +import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'; + +const NotFound = () => { + const navigate = useNavigate(); + + return ( + + + + + +

404 - Page Not Found

+

The page you are looking for does not exist. It may have been moved or deleted.

+ +
+
+
+
+ ); +}; + +export default NotFound; diff --git a/src/ui/views/Login/Login.jsx b/src/ui/views/Login/Login.jsx index 719714ec2..ec8b3debd 100644 --- a/src/ui/views/Login/Login.jsx +++ b/src/ui/views/Login/Login.jsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; // @material-ui/core components import FormControl from '@material-ui/core/FormControl'; import InputLabel from '@material-ui/core/InputLabel'; @@ -12,10 +13,10 @@ import CardHeader from '../../components/Card/CardHeader'; import CardBody from '../../components/Card/CardBody'; import CardFooter from '../../components/Card/CardFooter'; import axios from 'axios'; -import { Navigate } from 'react-router-dom'; import logo from '../../assets/img/git-proxy.png'; import { Badge, CircularProgress, Snackbar } from '@material-ui/core'; import { getCookie } from '../../utils'; +import { useAuth } from '../../auth/AuthProvider'; const loginUrl = `${import.meta.env.VITE_API_URI}/api/auth/login`; @@ -23,10 +24,12 @@ export default function UserProfile() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [message, setMessage] = useState(''); - const [success, setSuccess] = useState(false); - const [gitAccountError, setGitAccountError] = useState(false); + const [, setGitAccountError] = useState(false); const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + const { refreshUser } = useAuth(); + function validateForm() { return ( username.length > 0 && username.length < 100 && password.length > 0 && password.length < 200 @@ -57,8 +60,8 @@ export default function UserProfile() { .then(function () { window.sessionStorage.setItem('git.proxy.login', 'success'); setMessage('Success!'); - setSuccess(true); setIsLoading(false); + refreshUser().then(() => navigate('/dashboard/repo')); }) .catch(function (error) { if (error.response.status === 307) { @@ -75,13 +78,6 @@ export default function UserProfile() { event.preventDefault(); } - if (gitAccountError) { - return ; - } - if (success) { - return ; - } - return (
{' '} - View our open source activity feed or{' '} - scroll through projects we contribute to + View our open source activity feed or{' '} + scroll through projects we contribute to diff --git a/src/ui/views/OpenPushRequests/components/PushesTable.jsx b/src/ui/views/OpenPushRequests/components/PushesTable.jsx index 2b7213c60..2a3a7f33a 100644 --- a/src/ui/views/OpenPushRequests/components/PushesTable.jsx +++ b/src/ui/views/OpenPushRequests/components/PushesTable.jsx @@ -28,7 +28,7 @@ export default function PushesTable(props) { const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 5; const [searchTerm, setSearchTerm] = useState(''); - const openPush = (push) => navigate(`/admin/push/${push}`, { replace: true }); + const openPush = (push) => navigate(`/dashboard/push/${push}`, { replace: true }); useEffect(() => { const query = {}; diff --git a/src/ui/views/PushDetails/PushDetails.jsx b/src/ui/views/PushDetails/PushDetails.jsx index ee493afdb..1f55c35d0 100644 --- a/src/ui/views/PushDetails/PushDetails.jsx +++ b/src/ui/views/PushDetails/PushDetails.jsx @@ -48,20 +48,20 @@ export default function Dashboard() { const authorise = async (attestationData) => { await authorisePush(id, setMessage, setUserAllowedToApprove, attestationData); if (isUserAllowedToApprove) { - navigate('/admin/push/'); + navigate('/dashboard/push/'); } }; const reject = async () => { await rejectPush(id, setMessage, setUserAllowedToReject); if (isUserAllowedToReject) { - navigate('/admin/push/'); + navigate('/dashboard/push/'); } }; const cancel = async () => { await cancelPush(id, setAuth, setIsError); - navigate(`/admin/push/`); + navigate(`/dashboard/push/`); }; if (isLoading) return
Loading...
; @@ -186,7 +186,6 @@ export default function Dashboard() { htmlColor='green' /> - {data.autoApproved ? ( <>
@@ -197,7 +196,7 @@ export default function Dashboard() { ) : ( <> - +

- + {data.attestation.reviewer.gitAccount} {' '} approved this contribution diff --git a/src/ui/views/PushDetails/components/AttestationView.jsx b/src/ui/views/PushDetails/components/AttestationView.jsx index 70540ca76..9ccbfc8a8 100644 --- a/src/ui/views/PushDetails/components/AttestationView.jsx +++ b/src/ui/views/PushDetails/components/AttestationView.jsx @@ -62,7 +62,7 @@ export default function AttestationView(props) {

Prior to making this code contribution publicly accessible via GitHub, this code contribution was reviewed and approved by{' '} - + {props.data.reviewer.gitAccount} . As a reviewer, it was their responsibility to confirm that open sourcing this @@ -72,7 +72,7 @@ export default function AttestationView(props) {

- + {props.data.reviewer.gitAccount} {' '} approved this contribution{' '} diff --git a/src/ui/views/RepoDetails/RepoDetails.jsx b/src/ui/views/RepoDetails/RepoDetails.jsx index 9c91c1b68..56d80e278 100644 --- a/src/ui/views/RepoDetails/RepoDetails.jsx +++ b/src/ui/views/RepoDetails/RepoDetails.jsx @@ -51,7 +51,7 @@ export default function RepoDetails() { const removeRepository = async (name) => { await deleteRepo(name); - navigate('/admin/repo', { replace: true }); + navigate('/dashboard/repo', { replace: true }); }; const refresh = () => getRepo(setIsLoading, setData, setAuth, setIsError, repoName); @@ -151,7 +151,7 @@ export default function RepoDetails() { return ( - {row} + {row} {user.admin && ( @@ -196,7 +196,7 @@ export default function RepoDetails() { return ( - {row} + {row} {user.admin && ( diff --git a/src/ui/views/RepoList/Components/RepoOverview.jsx b/src/ui/views/RepoList/Components/RepoOverview.jsx index a431dc721..9e98886df 100644 --- a/src/ui/views/RepoList/Components/RepoOverview.jsx +++ b/src/ui/views/RepoList/Components/RepoOverview.jsx @@ -581,6 +581,9 @@ export default function Repositories(props) { .get(`https://api.github.com/repos/${props.data.project}/${props.data.name}`) .then((res) => { setGitHub(res.data); + }) + .catch((err) => { + console.error(`Error fetching GitHub repository ${props.data.project}/${props.data.name}: ${err}`); }); }; @@ -591,7 +594,7 @@ export default function Repositories(props) {

- + {props.data.project}/{props.data.name} diff --git a/src/ui/views/RepoList/Components/Repositories.jsx b/src/ui/views/RepoList/Components/Repositories.jsx index 4be87b36d..ac9663423 100644 --- a/src/ui/views/RepoList/Components/Repositories.jsx +++ b/src/ui/views/RepoList/Components/Repositories.jsx @@ -29,7 +29,7 @@ export default function Repositories(props) { const itemsPerPage = 5; const navigate = useNavigate(); const { user } = useContext(UserContext); - const openRepo = (repo) => navigate(`/admin/repo/${repo}`, { replace: true }); + const openRepo = (repo) => navigate(`/dashboard/repo/${repo}`, { replace: true }); useEffect(() => { const query = {}; diff --git a/src/ui/views/User/User.jsx b/src/ui/views/User/User.jsx index c8b46ebe5..44fa64e1c 100644 --- a/src/ui/views/User/User.jsx +++ b/src/ui/views/User/User.jsx @@ -52,7 +52,7 @@ export default function Dashboard() { if (isLoading) return
Loading...
; if (isError) return
Something went wrong ...
; - if (!auth && window.location.pathname === '/admin/profile') { + if (!auth && window.location.pathname === '/dashboard/profile') { return ; } @@ -60,7 +60,7 @@ export default function Dashboard() { try { data.gitAccount = escapeHTML(gitAccount); await updateUser(data); - navigate(`/admin/user/${data.username}`); + navigate(`/dashboard/profile`); } catch { setIsError(true); } diff --git a/src/ui/views/UserList/Components/UserList.jsx b/src/ui/views/UserList/Components/UserList.jsx index f148a8384..ee6812485 100644 --- a/src/ui/views/UserList/Components/UserList.jsx +++ b/src/ui/views/UserList/Components/UserList.jsx @@ -31,7 +31,7 @@ export default function UserList(props) { const itemsPerPage = 5; const [searchQuery, setSearchQuery] = useState(''); - const openUser = (username) => navigate(`/admin/user/${username}`, { replace: true }); + const openUser = (username) => navigate(`/dashboard/admin/user/${username}`, { replace: true }); useEffect(() => { diff --git a/test/testConfig.test.js b/test/testConfig.test.js index 9f5f45419..66b4f5be4 100644 --- a/test/testConfig.test.js +++ b/test/testConfig.test.js @@ -11,8 +11,9 @@ describe('default configuration', function () { it('should use default values if no user-settings.json file exists', function () { const config = require('../src/config'); config.logConfiguration(); + const enabledMethods = defaultSettings.authentication.filter(method => method.enabled); - expect(config.getAuthentication()).to.be.eql(defaultSettings.authentication[0]); + expect(config.getAuthMethods()).to.deep.equal(enabledMethods); expect(config.getDatabase()).to.be.eql(defaultSettings.sink[0]); expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword); expect(config.getAuthorisedList()).to.be.eql(defaultSettings.authorisedList); @@ -47,9 +48,10 @@ describe('user configuration', function () { fs.writeFileSync(tempUserFile, JSON.stringify(user)); const config = require('../src/config'); + const enabledMethods = defaultSettings.authentication.filter(method => method.enabled); expect(config.getAuthorisedList()).to.be.eql(user.authorisedList); - expect(config.getAuthentication()).to.be.eql(defaultSettings.authentication[0]); + expect(config.getAuthMethods()).to.deep.equal(enabledMethods); expect(config.getDatabase()).to.be.eql(defaultSettings.sink[0]); expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword); }); @@ -66,9 +68,13 @@ describe('user configuration', function () { fs.writeFileSync(tempUserFile, JSON.stringify(user)); const config = require('../src/config'); + const authMethods = config.getAuthMethods(); + const googleAuth = authMethods.find(method => method.type === 'google'); - expect(config.getAuthentication()).to.be.eql(user.authentication[0]); - expect(config.getAuthentication()).to.not.be.eql(defaultSettings.authentication[0]); + expect(googleAuth).to.not.be.undefined; + expect(googleAuth.enabled).to.be.true; + expect(config.getAuthMethods()).to.deep.include(user.authentication[0]); + expect(config.getAuthMethods()).to.not.be.eql(defaultSettings.authentication); expect(config.getDatabase()).to.be.eql(defaultSettings.sink[0]); expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword); }); @@ -85,10 +91,11 @@ describe('user configuration', function () { fs.writeFileSync(tempUserFile, JSON.stringify(user)); const config = require('../src/config'); + const enabledMethods = defaultSettings.authentication.filter(method => method.enabled); expect(config.getDatabase()).to.be.eql(user.sink[0]); expect(config.getDatabase()).to.not.be.eql(defaultSettings.sink[0]); - expect(config.getAuthentication()).to.be.eql(defaultSettings.authentication[0]); + expect(config.getAuthMethods()).to.deep.equal(enabledMethods); expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword); }); diff --git a/test/testJwtAuthHandler.test.js b/test/testJwtAuthHandler.test.js new file mode 100644 index 000000000..af2bb1bb2 --- /dev/null +++ b/test/testJwtAuthHandler.test.js @@ -0,0 +1,193 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const axios = require('axios'); +const jwt = require('jsonwebtoken'); +const { jwkToBuffer } = require('jwk-to-pem'); + +const { assignRoles, getJwks, validateJwt } = require('../src/service/passport/jwtUtils'); +const jwtAuthHandler = require('../src/service/passport/jwtAuthHandler'); + +describe('getJwks', () => { + it('should fetch JWKS keys from authority', async () => { + const jwksResponse = { keys: [{ kid: 'test-key', kty: 'RSA', n: 'abc', e: 'AQAB' }] }; + + const getStub = sinon.stub(axios, 'get'); + getStub.onFirstCall().resolves({ data: { jwks_uri: 'https://mock.com/jwks' } }); + getStub.onSecondCall().resolves({ data: jwksResponse }); + + const keys = await getJwks('https://mock.com'); + expect(keys).to.deep.equal(jwksResponse.keys); + + getStub.restore(); + }); + + it('should throw error if fetch fails', async () => { + const stub = sinon.stub(axios, 'get').rejects(new Error('Network fail')); + try { + await getJwks('https://fail.com'); + } catch (err) { + expect(err.message).to.equal('Failed to fetch JWKS'); + } + stub.restore(); + }); +}); + +describe('validateJwt', () => { + let decodeStub; + let verifyStub; + let pemStub; + let getJwksStub; + + beforeEach(() => { + const jwksResponse = { keys: [{ kid: 'test-key', kty: 'RSA', n: 'abc', e: 'AQAB' }] }; + const getStub = sinon.stub(axios, 'get'); + getStub.onFirstCall().resolves({ data: { jwks_uri: 'https://mock.com/jwks' } }); + getStub.onSecondCall().resolves({ data: jwksResponse }); + + getJwksStub = sinon.stub().resolves(jwksResponse.keys); + decodeStub = sinon.stub(jwt, 'decode'); + verifyStub = sinon.stub(jwt, 'verify'); + pemStub = sinon.stub(jwkToBuffer); + + pemStub.returns('fake-public-key'); + getJwksStub.returns(jwksResponse.keys); + }); + + afterEach(() => sinon.restore()); + + it('should validate a correct JWT', async () => { + const mockJwk = { kid: '123', kty: 'RSA', n: 'abc', e: 'AQAB' }; + const mockPem = 'fake-public-key'; + + decodeStub.returns({ header: { kid: '123' } }); + getJwksStub.resolves([mockJwk]); + pemStub.returns(mockPem); + verifyStub.returns({ azp: 'client-id', sub: 'user123' }); + + const { verifiedPayload } = await validateJwt('fake.token.here', 'https://issuer.com', 'client-id', 'client-id', getJwksStub); + expect(verifiedPayload.sub).to.equal('user123'); + }); + + it('should return error if JWT invalid', async () => { + decodeStub.returns(null); // Simulate broken token + + const { error } = await validateJwt('bad.token', 'https://issuer.com', 'client-id', 'client-id', getJwksStub); + expect(error).to.include('Invalid JWT'); + }); +}); + +describe('assignRoles', () => { + it('should assign admin role based on claim', () => { + const user = { username: 'admin-user' }; + const payload = { admin: 'admin' }; + const mapping = { admin: { 'admin': 'admin' } }; + + assignRoles(mapping, payload, user); + expect(user.admin).to.be.true; + }); + + it('should assign multiple roles based on claims', () => { + const user = { username: 'multi-role-user' }; + const payload = { 'custom-claim-admin': 'custom-value', 'editor': 'editor' }; + const mapping = { admin: { 'custom-claim-admin': 'custom-value' }, editor: { 'editor': 'editor' } }; + + assignRoles(mapping, payload, user); + expect(user.admin).to.be.true; + expect(user.editor).to.be.true; + }); + + it('should not assign role if claim mismatch', () => { + const user = { username: 'basic-user' }; + const payload = { admin: 'nope' }; + const mapping = { admin: { admin: 'admin' } }; + + assignRoles(mapping, payload, user); + expect(user.admin).to.be.undefined; + }); + + it('should not assign role if no mapping provided', () => { + const user = { username: 'no-role-user' }; + const payload = { admin: 'admin' }; + + assignRoles(null, payload, user); + expect(user.admin).to.be.undefined; + }); +}); + +describe('jwtAuthHandler', () => { + let req; + let res; + let next; + let jwtConfig; + let validVerifyResponse; + + beforeEach(() => { + req = { header: sinon.stub(), isAuthenticated: sinon.stub(), user: {} }; + res = { status: sinon.stub().returnsThis(), send: sinon.stub() }; + next = sinon.stub(); + + jwtConfig = { + clientID: 'client-id', + authorityURL: 'https://accounts.google.com', + expectedAudience: 'expected-audience', + roleMapping: { 'admin': { 'admin': 'admin' } } + }; + + validVerifyResponse = { + header: { kid: '123' }, + azp: 'client-id', + sub: 'user123', + admin: 'admin' + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should call next if user is authenticated', async () => { + req.isAuthenticated.returns(true); + await jwtAuthHandler()(req, res, next); + expect(next.calledOnce).to.be.true; + }); + + it('should return 401 if no token provided', async () => { + req.header.returns(null); + await jwtAuthHandler(jwtConfig)(req, res, next); + + expect(res.status.calledWith(401)).to.be.true; + expect(res.send.calledWith('No token provided\n')).to.be.true; + }); + + it('should return 500 if authorityURL not configured', async () => { + req.header.returns('Bearer fake-token'); + jwtConfig.authorityURL = null; + sinon.stub(jwt, 'verify').returns(validVerifyResponse); + + await jwtAuthHandler(jwtConfig)(req, res, next); + + expect(res.status.calledWith(500)).to.be.true; + expect(res.send.calledWith('OIDC authority URL is not configured\n')).to.be.true; + }); + + it('should return 500 if clientID not configured', async () => { + req.header.returns('Bearer fake-token'); + jwtConfig.clientID = null; + sinon.stub(jwt, 'verify').returns(validVerifyResponse); + + await jwtAuthHandler(jwtConfig)(req, res, next); + + expect(res.status.calledWith(500)).to.be.true; + expect(res.send.calledWith('OIDC client ID is not configured\n')).to.be.true; + }); + + it('should return 401 if JWT validation fails', async () => { + req.header.returns('Bearer fake-token'); + sinon.stub(jwt, 'verify').throws(new Error('Invalid token')); + + await jwtAuthHandler(jwtConfig)(req, res, next); + + expect(res.status.calledWith(401)).to.be.true; + expect(res.send.calledWithMatch(/JWT validation failed:/)).to.be.true; + }); +}); diff --git a/test/testLogin.test.js b/test/testLogin.test.js index 812e4f755..833184e0b 100644 --- a/test/testLogin.test.js +++ b/test/testLogin.test.js @@ -43,7 +43,7 @@ describe('auth', async () => { }); it('should now be able to access the user login metadata', async function () { - const res = await chai.request(app).get('/api/auth/userLoggedIn').set('Cookie', `${cookie}`); + const res = await chai.request(app).get('/api/auth/me').set('Cookie', `${cookie}`); res.should.have.status(200); });