From b0cf67230689f7fa0e0b0db8e1873ab35134f2df Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:59:30 -0700 Subject: [PATCH 1/2] feat: added oauth protocol support and mcp server APIs --- .env-cmdrc-template | 2 + .github/workflows/master.yml | 2 +- .github/workflows/sonar.yml | 2 +- README.md | 9 + config/.env.dev | 1 + src/api-docs/paths/path-oauth.js | 99 +++ src/api-docs/schemas/oauth.js | 209 ++++++ src/api-docs/swagger-document.js | 8 +- src/app.js | 2 + src/middleware/auth.js | 98 ++- src/models/oauth-authorization-code.js | 51 ++ src/models/oauth-client.js | 38 + src/models/oauth-token.js | 66 ++ src/routers/config.js | 25 +- src/routers/domain.js | 8 +- src/routers/environment.js | 4 +- src/routers/oauth.js | 212 ++++++ src/services/config.js | 35 + src/services/oauth.js | 357 ++++++++++ tests/config.test.js | 162 ++++- tests/domain.test.js | 88 ++- tests/fixtures/db_api.js | 2 + tests/oauth.test.js | 914 +++++++++++++++++++++++++ tests/services/oauth.test.js | 108 +++ 24 files changed, 2435 insertions(+), 67 deletions(-) create mode 100644 src/api-docs/paths/path-oauth.js create mode 100644 src/api-docs/schemas/oauth.js create mode 100644 src/models/oauth-authorization-code.js create mode 100644 src/models/oauth-client.js create mode 100644 src/models/oauth-token.js create mode 100644 src/routers/oauth.js create mode 100644 src/services/oauth.js create mode 100644 tests/oauth.test.js create mode 100644 tests/services/oauth.test.js diff --git a/.env-cmdrc-template b/.env-cmdrc-template index 9f70d265..a24d153a 100644 --- a/.env-cmdrc-template +++ b/.env-cmdrc-template @@ -39,6 +39,7 @@ "SWITCHER_API_KEY": "[SWITCHER_API_KEY]", "SWITCHER_API_DOMAIN": "Switcher API", "SWITCHER_API_ENVIRONMENT": "default", + "SWITCHER_MANAGEMENT_URL": "http://localhost:4200", "SWITCHER_SLACK_JWT_SECRET": "[SWITCHER_SLACK_JWT_SECRET]", "SWITCHER_GITOPS_JWT_SECRET": "[SWITCHER_GITOPS_JWT_SECRET]", @@ -71,6 +72,7 @@ "SWITCHER_API_LOGGER_LEVEL": "debug", "SWITCHER_API_ENABLE": false, "SWITCHER_API_THROTTLE": 5000, + "SWITCHER_MANAGEMENT_URL": "http://localhost:4200", "SWITCHER_SLACK_JWT_SECRET": "[SWITCHER_SLACK_JWT_SECRET]", "SWITCHER_GITOPS_JWT_SECRET": "[SWITCHER_GITOPS_JWT_SECRET]", "SWITCHER_GITOPS_URL": "http://localhost:8000" diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index e544a9ba..d082cff7 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -60,7 +60,7 @@ jobs: SWITCHER_API_LOGGER: false - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v8.2.1 + uses: sonarsource/sonarqube-scan-action@v8.3.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 527f99a0..199ca041 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -75,7 +75,7 @@ jobs: SWITCHER_API_LOGGER: false - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v8.2.1 + uses: sonarsource/sonarqube-scan-action@v8.3.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' diff --git a/README.md b/README.md index 88ea8685..36ef23d0 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,15 @@ Switcher API supports multiple auth providers such as email/password-based authe 3. Enable SAML authentication in Switcher Management by setting the environment variable SAML_ENABLE=true * `service_provider` refers to Switcher API + +#### Dynamic OAuth clients (e.g. MCP server) consent screen + +OAuth clients created via `POST /oauth/register` (such as switcher-mcp-server) are redirected to a +browser-based consent screen hosted by Switcher Management at `/oauth/consent`, rather than directly +to this API. Set `SWITCHER_MANAGEMENT_URL` to your Switcher Management base URL so the +`authorization_endpoint` advertised at `/.well-known/oauth-authorization-server` points there +(if unset, it falls back to `SAML_REDIRECT_ENDPOINT_URL`, then to this API's own `/oauth/authorize`, +which has no consent UI). * `web_app` refers to Switcher Management ### Running Switcher API from Docker Composer manifest file diff --git a/config/.env.dev b/config/.env.dev index fa50f978..c9b17d79 100644 --- a/config/.env.dev +++ b/config/.env.dev @@ -16,6 +16,7 @@ HISTORY_ACTIVATED=true METRICS_MAX_PAGE=50 GOOGLE_SKIP_AUTH=true SWITCHER_API_LOGGER=true +SWITCHER_MANAGEMENT_URL=http://localhost:4200 ### SAML Configuration SAML_ENTRY_POINT= diff --git a/src/api-docs/paths/path-oauth.js b/src/api-docs/paths/path-oauth.js new file mode 100644 index 00000000..3b6c909d --- /dev/null +++ b/src/api-docs/paths/path-oauth.js @@ -0,0 +1,99 @@ +import { pathParameter, queryParameter } from '../schemas/common.js'; +import { commonSchemaContent } from './common.js'; + +export default { + '/oauth/register': { + post: { + tags: ['OAuth'], + description: 'Register a public OAuth client using dynamic client registration', + requestBody: { + content: commonSchemaContent('OAuthClientRegistrationRequest') + }, + responses: { + 201: { + description: 'OAuth client registered', + content: commonSchemaContent('OAuthClientRegistrationResponse') + } + } + } + }, + '/oauth/authorize': { + get: { + tags: ['OAuth'], + description: 'Create an authorization code for a logged-in admin', + security: [{ bearerAuth: [] }], + parameters: [ + queryParameter('response_type', 'OAuth response type', true), + queryParameter('client_id', 'OAuth client id', true), + queryParameter('redirect_uri', 'Registered redirect uri', true), + queryParameter('scope', 'Requested OAuth scope', false), + queryParameter('state', 'Client-provided state', false), + queryParameter('code_challenge', 'PKCE SHA-256 code challenge', true), + queryParameter('code_challenge_method', 'PKCE code challenge method', true), + queryParameter('consent', 'Consent flag', false, 'boolean') + ], + responses: { + 302: { + description: 'Redirect back to the client with authorization result' + } + } + } + }, + '/oauth/token': { + post: { + tags: ['OAuth'], + description: 'Exchange an authorization code or refresh token for an access token', + requestBody: { + content: commonSchemaContent('OAuthTokenRequest') + }, + responses: { + 200: { + description: 'OAuth token response', + content: commonSchemaContent('OAuthTokenResponse') + } + } + } + }, + '/.well-known/oauth-authorization-server': { + get: { + tags: ['OAuth'], + description: 'OAuth authorization server metadata', + responses: { + 200: { + description: 'Authorization server metadata', + content: commonSchemaContent('OAuthAuthorizationServerMetadata') + } + } + } + }, + '/.well-known/oauth-protected-resource': { + get: { + tags: ['OAuth'], + description: 'OAuth protected resource metadata', + responses: { + 200: { + description: 'Protected resource metadata', + content: commonSchemaContent('OAuthProtectedResourceMetadata') + } + } + } + }, + '/config/key/{key}': { + get: { + tags: ['Config'], + description: 'Get Config by key and domain name', + security: [{ bearerAuth: [] }], + parameters: [ + pathParameter('key', 'Config key', true), + queryParameter('domain', 'Domain name', true), + queryParameter('environment', 'Environment name', false) + ], + responses: { + 200: { + description: 'Config', + content: commonSchemaContent('ConfigByKeyResponse') + } + } + } + } +}; diff --git a/src/api-docs/schemas/oauth.js b/src/api-docs/schemas/oauth.js new file mode 100644 index 00000000..6cebb6df --- /dev/null +++ b/src/api-docs/schemas/oauth.js @@ -0,0 +1,209 @@ +import { config } from './config.js'; + +const oauthMetadata = { + type: 'object', + properties: { + issuer: { + type: 'string' + }, + authorization_endpoint: { + type: 'string' + }, + token_endpoint: { + type: 'string' + }, + registration_endpoint: { + type: 'string' + }, + scopes_supported: { + type: 'array', + items: { + type: 'string' + } + }, + response_types_supported: { + type: 'array', + items: { + type: 'string' + } + }, + grant_types_supported: { + type: 'array', + items: { + type: 'string' + } + }, + code_challenge_methods_supported: { + type: 'array', + items: { + type: 'string' + } + }, + token_endpoint_auth_methods_supported: { + type: 'array', + items: { + type: 'string' + } + } + } +}; + +export default { + OAuthClientRegistrationRequest: { + type: 'object', + required: ['redirect_uris'], + properties: { + client_name: { + type: 'string' + }, + redirect_uris: { + type: 'array', + items: { + type: 'string' + } + }, + grant_types: { + type: 'array', + items: { + type: 'string' + } + }, + response_types: { + type: 'array', + items: { + type: 'string' + } + }, + token_endpoint_auth_method: { + type: 'string' + }, + scope: { + type: 'string' + } + } + }, + OAuthClientRegistrationResponse: { + type: 'object', + properties: { + client_id: { + type: 'string' + }, + client_id_issued_at: { + type: 'integer' + }, + client_name: { + type: 'string' + }, + redirect_uris: { + type: 'array', + items: { + type: 'string' + } + }, + grant_types: { + type: 'array', + items: { + type: 'string' + } + }, + response_types: { + type: 'array', + items: { + type: 'string' + } + }, + token_endpoint_auth_method: { + type: 'string' + }, + scope: { + type: 'string' + } + } + }, + OAuthTokenRequest: { + type: 'object', + properties: { + grant_type: { + type: 'string' + }, + code: { + type: 'string' + }, + redirect_uri: { + type: 'string' + }, + client_id: { + type: 'string' + }, + code_verifier: { + type: 'string' + }, + refresh_token: { + type: 'string' + } + } + }, + OAuthTokenResponse: { + type: 'object', + properties: { + access_token: { + type: 'string' + }, + token_type: { + type: 'string' + }, + expires_in: { + type: 'integer' + }, + refresh_token: { + type: 'string' + }, + scope: { + type: 'string' + } + } + }, + OAuthAuthorizationServerMetadata: oauthMetadata, + OAuthProtectedResourceMetadata: { + ...oauthMetadata, + properties: { + resource: { + type: 'string' + }, + authorization_servers: { + type: 'array', + items: { + type: 'string' + } + }, + bearer_methods_supported: { + type: 'array', + items: { + type: 'string' + } + }, + ...oauthMetadata.properties + } + }, + ConfigByKeyResponse: { + ...config({ + type: 'array', + description: 'The component IDs that can use the Swichter', + items: { + type: 'string' + } + }), + properties: { + ...config({ + type: 'array', + description: 'The component IDs that can use the Swichter', + items: { + type: 'string' + } + }).properties, + enabled: { + type: 'boolean' + } + } + } +}; diff --git a/src/api-docs/swagger-document.js b/src/api-docs/swagger-document.js index 938726b5..66e140a1 100644 --- a/src/api-docs/swagger-document.js +++ b/src/api-docs/swagger-document.js @@ -11,6 +11,7 @@ import pathPermission from './paths/path-permission.js'; import pathMetric from './paths/path-metric.js'; import pathSlack from './paths/path-slack.js'; import pathGitOps from './paths/path-gitops.js'; +import pathOauth from './paths/path-oauth.js'; import { commonSchema } from './schemas/common.js'; import adminSchema from './schemas/admin.js'; @@ -25,6 +26,7 @@ import permissionSchema from './schemas/permission.js'; import metricSchema from './schemas/metric.js'; import slackSchema from './schemas/slack.js'; import gitOpsSchema from './schemas/gitops.js'; +import oauthSchema from './schemas/oauth.js'; import info from './swagger-info.js'; export default { @@ -71,7 +73,8 @@ export default { ...permissionSchema, ...metricSchema, ...slackSchema, - ...gitOpsSchema + ...gitOpsSchema, + ...oauthSchema } }, paths: { @@ -87,6 +90,7 @@ export default { ...pathPermission, ...pathMetric, ...pathSlack, - ...pathGitOps + ...pathGitOps, + ...pathOauth } }; \ No newline at end of file diff --git a/src/app.js b/src/app.js index 4eb04b8e..cded6eba 100644 --- a/src/app.js +++ b/src/app.js @@ -20,6 +20,7 @@ import configStrategyRouter from './routers/config-strategy.js'; import metricRouter from './routers/metric.js'; import teamRouter from './routers/team.js'; import permissionRouter from './routers/permission.js'; +import oauthRouter from './routers/oauth.js'; import slackRouter from './routers/slack.js'; import gitOpsRouter from './routers/gitops.js'; import schema from './aggregator/schema.js'; @@ -67,6 +68,7 @@ app.use(configStrategyRouter); app.use(metricRouter); app.use(teamRouter); app.use(permissionRouter); +app.use(oauthRouter); app.use(slackRouter); app.use(gitOpsRouter); diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 57001389..3c109802 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,23 +1,45 @@ import basicAuth from 'express-basic-auth'; import jwt from 'jsonwebtoken'; import { getAdmin, getAdminById } from '../services/admin.js'; +import { validateAccessToken } from '../services/oauth.js'; import Admin from '../models/admin.js'; import { responseExceptionSilent } from '../exceptions/index.js'; import { EnvType } from '../models/environment.js'; +function getBearerToken(req) { + return req.header('Authorization')?.replace('Bearer ', ''); +} + +async function validateAdminJwt(token) { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const admin = await getAdminById(decoded._id); + + if (!admin?.active) { + throw new Error('User not active'); + } + + if (admin.token !== Admin.extractTokenPart(token)) { + throw new Error(`Invalid token for ${admin.email}`); + } + + return admin; +} + +/** + * Session-only authentication middleware. + * + * Accepts **only** a switcher-management session JWT (as issued by + * `Admin.generateAuthToken()`). Any other bearer token (e.g. an MCP/OAuth + * access token) is rejected with 401. + * + * Use `auth` for routes that must remain strictly session-based - i.e. + * anything not explicitly meant to be reachable by an MCP/OAuth client + * (the default for all existing routes unless documented otherwise). + */ export async function auth(req, res, next) { try { - const token = req.header('Authorization')?.replace('Bearer ', ''); - const decoded = jwt.verify(token, process.env.JWT_SECRET); - const admin = await getAdminById(decoded._id); - - if (!admin?.active) { - throw new Error('User not active'); - } - - if (admin.token !== Admin.extractTokenPart(token)) { - throw new Error(`Invalid token for ${admin.email}`); - } + const token = getBearerToken(req); + const admin = await validateAdminJwt(token); req.token = token; req.admin = admin; @@ -31,9 +53,61 @@ export async function auth(req, res, next) { } } +/** + * Dual-strategy authentication middleware: accepts **either** a + * switcher-management session JWT (`validateAdminJwt`) **or** an MCP + * OAuth access token (`validateAccessToken`). + * + * Strategy order is picked heuristically from the token shape: JWTs are + * three dot-separated base64url segments, while the opaque OAuth tokens + * generated by `generateRandomValue()` never contain a `.`. This is a + * heuristic, not a format guarantee - if either token format ever + * changes, revisit this. + * + * Only the **first** matching strategy is tried on its "home" token + * shape; if that strategy's token is expired (`TokenExpiredError`), the + * request fails fast with 401 `{ error: 'Token expired.' }` rather than + * falling through to the other strategy. If neither strategy succeeds + * (and it wasn't an expiry), the request fails with a generic 401 + * `Please authenticate.`. + * + * Use `authAny` only for routes that must be reachable by both + * switcher-management (JWT) **and** MCP/OAuth clients - as of this + * writing: `GET /domain`, `GET /domain/collaboration`, `GET /environment`, + * and `GET /config/key/:key`. Don't default new routes to `authAny`; + * keep using plain `auth` unless the route is a deliberate MCP tool + * target (see `docs/copilot/switcher-mcp/MCP_DEVELOPMENT.md`). + */ +export async function authAny(req, res, next) { + const token = getBearerToken(req); + const authStrategies = token?.includes('.') + ? [validateAdminJwt, validateAccessToken] + : [validateAccessToken, validateAdminJwt]; + + try { + for (const strategy of authStrategies) { + try { + const admin = await strategy(token); + + req.token = token; + req.admin = admin; + return next(); + } catch (err) { + if (err.name === 'TokenExpiredError') { + return res.status(401).send({ error: 'Token expired.' }); + } + } + } + + throw new Error('Invalid token'); + } catch (err) { + responseExceptionSilent(res, err, 401, 'Please authenticate.'); + } +} + export async function authRefreshToken(req, res, next) { try { - const token = req.header('Authorization')?.replace('Bearer ', ''); + const token = getBearerToken(req); const refreshToken = req.body.refreshToken; const decodedRefreshToken = jwt.verify(refreshToken, process.env.JWT_SECRET); diff --git a/src/models/oauth-authorization-code.js b/src/models/oauth-authorization-code.js new file mode 100644 index 00000000..8f39b20b --- /dev/null +++ b/src/models/oauth-authorization-code.js @@ -0,0 +1,51 @@ +import mongoose from 'mongoose'; + +const oauthAuthorizationCodeSchema = new mongoose.Schema({ + code: { + type: String, + required: true, + unique: true, + index: true + }, + client_id: { + type: String, + required: true, + index: true + }, + admin: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: 'Admin' + }, + redirect_uri: { + type: String, + required: true + }, + scope: { + type: String, + default: 'config:read' + }, + code_challenge: { + type: String, + required: true + }, + code_challenge_method: { + type: String, + enum: ['S256'], + required: true + }, + expiresAt: { + type: Date, + required: true + }, + used: { + type: Boolean, + default: false + } +}, { + timestamps: true +}); + +oauthAuthorizationCodeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +export const OAuthAuthorizationCode = mongoose.model('OAuthAuthorizationCode', oauthAuthorizationCodeSchema); diff --git a/src/models/oauth-client.js b/src/models/oauth-client.js new file mode 100644 index 00000000..3bed5f2a --- /dev/null +++ b/src/models/oauth-client.js @@ -0,0 +1,38 @@ +import mongoose from 'mongoose'; + +const oauthClientSchema = new mongoose.Schema({ + client_id: { + type: String, + required: true, + unique: true, + index: true + }, + client_name: { + type: String, + trim: true + }, + redirect_uris: { + type: [String], + required: true + }, + grant_types: { + type: [String], + default: ['authorization_code', 'refresh_token'] + }, + response_types: { + type: [String], + default: ['code'] + }, + token_endpoint_auth_method: { + type: String, + default: 'none' + }, + scope: { + type: String, + default: 'config:read' + } +}, { + timestamps: true +}); + +export const OAuthClient = mongoose.model('OAuthClient', oauthClientSchema); diff --git a/src/models/oauth-token.js b/src/models/oauth-token.js new file mode 100644 index 00000000..b680ccd1 --- /dev/null +++ b/src/models/oauth-token.js @@ -0,0 +1,66 @@ +import mongoose from 'mongoose'; +import moment from 'moment'; + +const oauthTokenSchema = new mongoose.Schema({ + access_token: { + type: String, + required: true, + unique: true, + index: true + }, + refresh_token: { + type: String, + required: true, + unique: true, + index: true + }, + client_id: { + type: String, + required: true, + index: true + }, + admin: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: 'Admin' + }, + scope: { + type: String, + default: 'config:read' + }, + accessTokenExpiresAt: { + type: Date, + required: true + }, + refreshTokenExpiresAt: { + type: Date, + required: true + }, + revoked: { + type: Boolean, + default: false + } +}, { + timestamps: true +}); + +oauthTokenSchema.index({ refreshTokenExpiresAt: 1 }, { expireAfterSeconds: 0 }); + +oauthTokenSchema.options.toJSON = { + getters: true, + virtuals: true, + minimize: false, + transform: function (_doc, ret) { + if (ret.updatedAt || ret.createdAt) { + ret.updatedAt = moment(ret.updatedAt).format('YYYY-MM-DD HH:mm:ss'); + ret.createdAt = moment(ret.createdAt).format('YYYY-MM-DD HH:mm:ss'); + } + + delete ret.access_token; + delete ret.refresh_token; + + return ret; + } +}; + +export const OAuthToken = mongoose.model('OAuthToken', oauthTokenSchema); diff --git a/src/routers/config.js b/src/routers/config.js index 677686ad..0898a727 100644 --- a/src/routers/config.js +++ b/src/routers/config.js @@ -1,7 +1,7 @@ import express from 'express'; import { body, check, query } from 'express-validator'; import { relayOptions } from '../models/config.js'; -import { auth } from '../middleware/auth.js'; +import { auth, authAny } from '../middleware/auth.js'; import { ActionTypes, RouterTypes } from '../models/permission.js'; import { responseException } from '../exceptions/index.js'; import { @@ -13,6 +13,7 @@ import { getHistory, deleteHistory } from '../services/history.js'; import { getGroupConfigById } from '../services/group-config.js'; import { SwitcherKeys } from '../external/switcher-api-facade.js'; import { getFields } from './common/index.js'; +import { EnvType } from '../models/environment.js'; const router = new express.Router(); @@ -65,6 +66,28 @@ router.get('/config', auth, [ } }); +router.get('/config/key/:key', authAny, [ + check('key').isLength({ min: 3, max: 50 }), + query('domain').isString(), + query('environment').optional().isString() +], validate, async (req, res) => { + try { + let config = await Services.getConfigByKey( + req.params.key, req.query.domain, req.query.environment, true); + config = await verifyOwnership(req.admin, config, config.domain, ActionTypes.READ, RouterTypes.CONFIG, true); + + const environment = req.query.environment || EnvType.DEFAULT; + const response = config.toJSON(); + response.enabled = config.activated?.has(environment) ? + config.activated.get(environment) === true : + config.activated?.get(EnvType.DEFAULT) === true; + + res.send(response); + } catch (e) { + responseException(res, e, 500); + } +}); + // GET /config/ID?resolveComponents=true router.get('/config/:id', auth, [ check('id').isMongoId() diff --git a/src/routers/domain.js b/src/routers/domain.js index 70babe7c..dc92a926 100644 --- a/src/routers/domain.js +++ b/src/routers/domain.js @@ -1,6 +1,6 @@ import express from 'express'; import { check } from 'express-validator'; -import { auth } from '../middleware/auth.js'; +import { auth, authAny } from '../middleware/auth.js'; import { validate, verifyInputUpdateParameters } from '../middleware/validators.js'; import { verifyOwnership, sortBy } from '../helpers/index.js'; import { ActionTypes, RouterTypes } from '../models/permission.js'; @@ -26,7 +26,7 @@ router.post('/domain/create', auth, verifyInputUpdateParameters(['name', 'descri // GET /domain?limit=10&skip=20 // GET /domain?sortBy=createdAt:desc -router.get('/domain', auth, async (req, res) => { +router.get('/domain', authAny, async (req, res) => { await req.admin.populate({ path: 'domain', options: { @@ -44,11 +44,11 @@ router.get('/domain', auth, async (req, res) => { res.send(req.admin.domain); }); -router.get('/domain/collaboration', auth, async (req, res) => { +router.get('/domain/collaboration', authAny, async (req, res) => { await req.admin.populate({ path: 'team_list' }); const domains = []; - for (const adm of req.admin.team_list) { + for await (const adm of req.admin.team_list) { domains.push(await Services.getDomainById(adm.domain.toString(), false, true)); } diff --git a/src/routers/environment.js b/src/routers/environment.js index 8f3bbf07..6645163f 100644 --- a/src/routers/environment.js +++ b/src/routers/environment.js @@ -1,6 +1,6 @@ import express from 'express'; import { check, query } from 'express-validator'; -import { auth } from '../middleware/auth.js'; +import { auth, authAny } from '../middleware/auth.js'; import { responseException } from '../exceptions/index.js'; import { validate, verifyInputUpdateParameters } from '../middleware/validators.js'; import * as Services from '../services/environment.js'; @@ -22,7 +22,7 @@ router.post('/environment/create', auth, verifyInputUpdateParameters([ // GET /environment?domain=ID&limit=10&skip=20 // GET /environment?domain=ID&sort=desc // GET /environment?domain=ID -router.get('/environment', auth, [ +router.get('/environment', authAny, [ query('domain', 'Please, specify the \'domain\' id').isMongoId() ], validate, async (req, res) => { try { diff --git a/src/routers/oauth.js b/src/routers/oauth.js new file mode 100644 index 00000000..0d845144 --- /dev/null +++ b/src/routers/oauth.js @@ -0,0 +1,212 @@ +import express from 'express'; +import { body, query, validationResult } from 'express-validator'; +import { responseException } from '../exceptions/index.js'; +import { auth } from '../middleware/auth.js'; +import { validate } from '../middleware/validators.js'; +import * as Services from '../services/oauth.js'; + +const router = new express.Router(); + +function getBaseUrl(req) { + return (process.env.SWITCHER_API_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, ''); +} + +function getManagementBaseUrl() { + // SAML_REDIRECT_ENDPOINT_URL already points at switcher-management for browser redirects + // (see admin-saml.js); reuse it as a fallback so existing deployments keep working. + const managementUrl = process.env.SWITCHER_MANAGEMENT_URL || process.env.SAML_REDIRECT_ENDPOINT_URL; + return managementUrl ? managementUrl.replace(/\/$/, '') : undefined; +} + +function buildMetadata(req) { + const issuer = getBaseUrl(req); + const managementBaseUrl = getManagementBaseUrl(); + + return { + issuer, + // The authorization endpoint must be a human-facing consent screen. switcher-api's own + // /oauth/authorize has no UI and requires an existing session, so this points at + // switcher-management's consent page, which collects user consent and then calls + // /oauth/authorize on this API with the result. + authorization_endpoint: managementBaseUrl ? `${managementBaseUrl}/oauth/consent` : `${issuer}/oauth/authorize`, + token_endpoint: `${issuer}/oauth/token`, + registration_endpoint: `${issuer}/oauth/register`, + scopes_supported: ['config:read'], + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'] + }; +} + +function getAuthorizeErrorRedirectUri(redirectUri, error, state) { + const url = new URL(redirectUri); + url.searchParams.set('error', error); + + if (state !== undefined) { + url.searchParams.set('state', state); + } + + return url.toString(); +} + +function wantsJsonRedirect(req) { + // switcher-management's consent screen calls this endpoint via an authenticated + // XHR/fetch (so it can attach the admin's session Authorization header, which a + // top-level browser navigation cannot do) and explicitly asks for JSON so it can + // perform the final top-level navigation to the client's redirect_uri itself. + return req.headers.accept === 'application/json'; +} + +function sendAuthorizeRedirect(req, res, redirectUri) { + if (wantsJsonRedirect(req)) { + return res.status(200).send({ redirect_uri: redirectUri }); + } + + return res.redirect(302, redirectUri); +} + +router.post('/oauth/register', [ + body('client_name').optional().isString(), + body('redirect_uris').isArray({ min: 1 }), + body('redirect_uris.*').isString(), + body('grant_types').optional().isArray(), + body('grant_types.*').optional().isString(), + body('response_types').optional().isArray(), + body('response_types.*').optional().isString(), + body('token_endpoint_auth_method').optional().isString(), + body('scope').optional().isString() +], validate, async (req, res) => { + try { + const client = await Services.registerClient(req.body); + res.status(201).send(client); + } catch (e) { + responseException(res, e, 400); + } +}); + +router.get('/oauth/authorize', auth, [ + query('response_type').optional().isString(), + query('client_id').optional().isString(), + query('redirect_uri').optional().isString(), + query('scope').optional().isString(), + query('state').optional().isString(), + query('code_challenge').optional().isString(), + query('code_challenge_method').optional().isString(), + query('consent').optional().isBoolean() +], async (req, res) => { + const { + response_type, + client_id, + redirect_uri, + scope, + state, + code_challenge, + code_challenge_method + } = req.query; + + try { + const client = await Services.getOAuthClientById(client_id); + + if (!Services.isRegisteredRedirectUri(client, redirect_uri)) { + throw new Error('Invalid redirect_uri'); + } + + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + return sendAuthorizeRedirect(req, res, getAuthorizeErrorRedirectUri(redirect_uri, 'invalid_request', state)); + } + + const consentGranted = req.query.consent !== 'false'; + + if (!response_type || !code_challenge || !code_challenge_method || !consentGranted) { + const error = consentGranted ? 'invalid_request' : 'access_denied'; + return sendAuthorizeRedirect(req, res, getAuthorizeErrorRedirectUri(redirect_uri, error, state)); + } + + if (response_type !== 'code' || code_challenge_method !== 'S256') { + return sendAuthorizeRedirect(req, res, getAuthorizeErrorRedirectUri(redirect_uri, 'invalid_request', state)); + } + + const code = await Services.createAuthorizationCode({ + client_id, + admin: req.admin, + redirect_uri, + scope, + code_challenge, + code_challenge_method + }); + + const redirectUrl = new URL(redirect_uri); + redirectUrl.searchParams.set('code', code); + + if (state !== undefined) { + redirectUrl.searchParams.set('state', state); + } + + sendAuthorizeRedirect(req, res, redirectUrl.toString()); + } catch (e) { + responseException(res, e, 400); + } +}); + +router.post('/oauth/token', [ + body('grant_type').isString(), + body('code').optional().isString(), + body('redirect_uri').optional().isString(), + body('client_id').isString(), + body('code_verifier').optional().isString(), + body('refresh_token').optional().isString() +], validate, async (req, res) => { + try { + let token; + + if (req.body.grant_type === 'authorization_code') { + token = await Services.exchangeAuthorizationCode(req.body); + } else if (req.body.grant_type === 'refresh_token') { + token = await Services.refreshAccessToken(req.body); + } else { + return res.status(400).send({ error: 'unsupported_grant_type' }); + } + + res.send(token); + } catch { + res.status(400).send({ error: 'invalid_grant' }); + } +}); + +router.get('/oauth/authorized', auth, async (req, res) => { + try { + const apps = await Services.listAuthorizedApps(req.admin); + res.send(apps); + } catch (e) { + responseException(res, e, 500); + } +}); + +router.delete('/oauth/authorized/:client_id', auth, async (req, res) => { + try { + await Services.revokeAuthorizedApp(req.admin, req.params.client_id); + res.send({ message: 'Authorized app revoked' }); + } catch (e) { + responseException(res, e, 400); + } +}); + +router.get('/.well-known/oauth-authorization-server', (req, res) => { + res.send(buildMetadata(req)); +}); + +router.get('/.well-known/oauth-protected-resource', (req, res) => { + const metadata = buildMetadata(req); + + res.send({ + resource: metadata.issuer, + authorization_servers: [metadata.issuer], + bearer_methods_supported: ['header'], + ...metadata + }); +}); + +export default router; diff --git a/src/services/config.js b/src/services/config.js index 58fa4b46..4654f53e 100644 --- a/src/services/config.js +++ b/src/services/config.js @@ -1,8 +1,10 @@ import { response } from './common.js'; import { Config } from '../models/config.js'; +import Domain from '../models/domain.js'; import { formatInput, verifyOwnership, checkEnvironmentStatusRemoval } from '../helpers/index.js'; import { ActionTypes, RouterTypes } from '../models/permission.js'; import { getDomainById, updateDomainVersion } from './domain.js'; +import { getEnvironmentByName } from './environment.js'; import { getGroupConfigById } from './group-config.js'; import { checkSwitcher } from '../external/switcher-api-facade.js'; import { BadRequestError, NotFoundError } from '../exceptions/index.js'; @@ -26,6 +28,39 @@ export async function getConfigById(id, populateAdmin = false) { return response(config, 'Config not found'); } +export async function getConfigByKey(key, domainName, environmentName, populateAdmin = false) { + const formattedKey = formatInput(key, { toUpper: true, autoUnderscore: true }); + const domain = await Domain.findOne({ name: domainName }).exec(); + + if (!domain) { + throw new Error('Domain not found'); + } + + if (environmentName) { + const environment = await getEnvironmentByName(domain._id, environmentName); + + if (!environment) { + throw new Error('Environment not found'); + } + } + + let config = await Config.findOne({ + key: formattedKey, + domain: domain._id + }).exec(); + + if (!config) { + throw new Error('Config not found'); + } + + if (populateAdmin) { + await config.populate({ path: 'admin', select: 'name' }); + await config.populate({ path: 'configStrategy' }); + } + + return config; +} + export async function getConfig(where) { const query = Config.findOne(); diff --git a/src/services/oauth.js b/src/services/oauth.js new file mode 100644 index 00000000..0f1ce617 --- /dev/null +++ b/src/services/oauth.js @@ -0,0 +1,357 @@ +import crypto from 'node:crypto'; +import { OAuthAuthorizationCode } from '../models/oauth-authorization-code.js'; +import { OAuthClient } from '../models/oauth-client.js'; +import { OAuthToken } from '../models/oauth-token.js'; + +const DEFAULT_SCOPE = 'config:read'; +const DEFAULT_GRANT_TYPES = ['authorization_code', 'refresh_token']; +const DEFAULT_RESPONSE_TYPES = ['code']; +const DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD = 'none'; +const AUTHORIZATION_CODE_TTL_IN_MS = 10 * 60 * 1000; +const ACCESS_TOKEN_TTL_IN_MS = 60 * 60 * 1000; +const REFRESH_TOKEN_TTL_IN_MS = 30 * 24 * 60 * 60 * 1000; + +function generateRandomValue() { + return crypto.randomBytes(32).toString('hex'); +} + +function resolveScope(scope) { + return scope || DEFAULT_SCOPE; +} + +function validateScope(scope) { + if (scope && scope !== DEFAULT_SCOPE) { + throw new Error('Invalid scope'); + } +} + +function validateSupportedValues(values, supportedValues, field) { + if (!values?.length) { + return; + } + + const hasUnsupportedValue = values.some((value) => !supportedValues.includes(value)); + + if (hasUnsupportedValue) { + throw new Error(`Invalid ${field}`); + } +} + +function normalizeRedirectUri(redirectUri) { + let url; + + try { + url = new URL(redirectUri); + } catch { + throw new Error('Invalid redirect_uri'); + } + + if (url.hash) { + throw new Error('Invalid redirect_uri'); + } + + const isLoopback = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname); + const isHttps = url.protocol === 'https:'; + + if (!isLoopback && !isHttps) { + throw new Error('Invalid redirect_uri'); + } + + return url.toString(); +} + +function verifyRedirectUri(client, redirectUri) { + const normalizedRedirectUri = normalizeRedirectUri(redirectUri); + + if (!client.redirect_uris.includes(normalizedRedirectUri)) { + throw new Error('Invalid redirect_uri'); + } + + return normalizedRedirectUri; +} + +function buildPkceChallenge(codeVerifier) { + return crypto.createHash('sha256') + .update(codeVerifier) + .digest('base64url'); +} + +function buildTokenPayload(token) { + return { + access_token: token.access_token, + token_type: 'Bearer', + expires_in: Math.max(0, Math.floor((token.accessTokenExpiresAt.getTime() - Date.now()) / 1000)), + refresh_token: token.refresh_token, + scope: token.scope + }; +} + +function buildRegistrationResponse(client) { + return { + client_id: client.client_id, + client_id_issued_at: Math.floor(client.createdAt.getTime() / 1000), + client_name: client.client_name, + redirect_uris: client.redirect_uris, + grant_types: client.grant_types, + response_types: client.response_types, + token_endpoint_auth_method: client.token_endpoint_auth_method, + scope: client.scope + }; +} + +async function getClient(client_id) { + const client = await OAuthClient.findOne({ client_id }).exec(); + + if (!client) { + throw new Error('Invalid client_id'); + } + + return client; +} + +async function issueTokenPair({ client_id, admin, scope }) { + const now = Date.now(); + + const token = new OAuthToken({ + access_token: generateRandomValue(), + refresh_token: generateRandomValue(), + client_id, + admin: admin._id || admin, + scope: resolveScope(scope), + accessTokenExpiresAt: new Date(now + ACCESS_TOKEN_TTL_IN_MS), + refreshTokenExpiresAt: new Date(now + REFRESH_TOKEN_TTL_IN_MS) + }); + + await token.save(); + return token; +} + +export function isRegisteredRedirectUri(client, redirectUri) { + try { + verifyRedirectUri(client, redirectUri); + return true; + } catch { + return false; + } +} + +export async function getOAuthClientById(client_id) { + return getClient(client_id); +} + +export async function registerClient({ + client_name, + redirect_uris, + grant_types, + response_types, + token_endpoint_auth_method, + scope +}) { + if (!redirect_uris?.length) { + throw new Error('redirect_uris is required'); + } + + if (token_endpoint_auth_method && token_endpoint_auth_method !== DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD) { + throw new Error('Invalid token_endpoint_auth_method'); + } + + validateScope(scope); + validateSupportedValues(grant_types, DEFAULT_GRANT_TYPES, 'grant_types'); + validateSupportedValues(response_types, DEFAULT_RESPONSE_TYPES, 'response_types'); + + const client = new OAuthClient({ + client_id: generateRandomValue(), + client_name, + redirect_uris: redirect_uris.map(normalizeRedirectUri), + grant_types: grant_types?.length ? grant_types : DEFAULT_GRANT_TYPES, + response_types: response_types?.length ? response_types : DEFAULT_RESPONSE_TYPES, + token_endpoint_auth_method: token_endpoint_auth_method || DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD, + scope: resolveScope(scope) + }); + + await client.save(); + return buildRegistrationResponse(client); +} + +export async function createAuthorizationCode({ + client_id, + admin, + redirect_uri, + scope, + code_challenge, + code_challenge_method +}) { + const client = await getClient(client_id); + const resolvedRedirectUri = verifyRedirectUri(client, redirect_uri); + + validateScope(scope); + + if (!code_challenge) { + throw new Error('Invalid code_challenge'); + } + + if (code_challenge_method !== 'S256') { + throw new Error('Invalid code_challenge_method'); + } + + const authorizationCode = new OAuthAuthorizationCode({ + code: generateRandomValue(), + client_id, + admin: admin._id || admin, + redirect_uri: resolvedRedirectUri, + scope: resolveScope(scope || client.scope), + code_challenge, + code_challenge_method, + expiresAt: new Date(Date.now() + AUTHORIZATION_CODE_TTL_IN_MS) + }); + + await authorizationCode.save(); + return authorizationCode.code; +} + +export async function exchangeAuthorizationCode({ + client_id, + code, + redirect_uri, + code_verifier +}) { + const client = await getClient(client_id); + const resolvedRedirectUri = verifyRedirectUri(client, redirect_uri); + + if (!code_verifier) { + throw new Error('Invalid code_verifier'); + } + + const authorizationCode = await OAuthAuthorizationCode.findOne({ + code, + client_id + }).exec(); + + if (!authorizationCode || authorizationCode.used) { + throw new Error('Invalid authorization code'); + } + + if (authorizationCode.expiresAt.getTime() <= Date.now()) { + throw new Error('Authorization code expired'); + } + + authorizationCode.used = true; + await authorizationCode.save(); + + if (authorizationCode.redirect_uri !== resolvedRedirectUri) { + throw new Error('Invalid redirect_uri'); + } + + if (authorizationCode.code_challenge_method !== 'S256') { + throw new Error('Invalid code_challenge_method'); + } + + if (buildPkceChallenge(code_verifier) !== authorizationCode.code_challenge) { + throw new Error('Invalid code_verifier'); + } + + const token = await issueTokenPair({ + client_id, + admin: authorizationCode.admin, + scope: authorizationCode.scope || client.scope + }); + + return buildTokenPayload(token); +} + +export async function refreshAccessToken({ + client_id, + refresh_token +}) { + if (!refresh_token) { + throw new Error('Invalid refresh token'); + } + + await getClient(client_id); + + const token = await OAuthToken.findOne({ + client_id, + refresh_token, + revoked: false + }).exec(); + + if (!token) { + throw new Error('Invalid refresh token'); + } + + if (token.refreshTokenExpiresAt.getTime() <= Date.now()) { + throw new Error('Refresh token expired'); + } + + token.revoked = true; + await token.save(); + + const refreshedToken = await issueTokenPair({ + client_id, + admin: token.admin, + scope: token.scope + }); + + return buildTokenPayload(refreshedToken); +} + +export async function listAuthorizedApps(admin) { + const tokens = await OAuthToken.find({ + admin: admin._id || admin, + revoked: false, + refreshTokenExpiresAt: { $gt: new Date() } + }).sort({ createdAt: -1 }).exec(); + + const clientIds = [...new Set(tokens.map((token) => token.client_id))]; + const clients = await OAuthClient.find({ client_id: { $in: clientIds } }).exec(); + const clientsById = new Map(clients.map((client) => [client.client_id, client])); + + return clientIds.map((client_id) => { + const client = clientsById.get(client_id); + const latestToken = tokens.find((token) => token.client_id === client_id); + + return { + client_id, + client_name: client?.client_name, + scope: latestToken.scope, + createdAt: latestToken.toJSON().createdAt + }; + }); +} + +export async function revokeAuthorizedApp(admin, client_id) { + const result = await OAuthToken.updateMany({ + admin: admin._id || admin, + client_id, + revoked: false + }, { + revoked: true + }).exec(); + + if (!result.matchedCount) { + throw new Error('Authorized app not found'); + } +} + +export async function validateAccessToken(accessToken) { + const token = await OAuthToken.findOne({ + access_token: accessToken, + revoked: false + }).populate({ + path: 'admin' + }).exec(); + + if (!token) { + throw new Error('Invalid access token'); + } + + if (token.accessTokenExpiresAt.getTime() <= Date.now()) { + throw new Error('Access token expired'); + } + + if (!token.admin?.active) { + throw new Error('User not active'); + } + + return token.admin; +} diff --git a/tests/config.test.js b/tests/config.test.js index ce1196b5..61ab2870 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -146,15 +146,18 @@ describe('Testing fetch configuration info', () => { }); test('CONFIG_SUITE - Should NOT get Config information by invalid Group Id', async () => { - await request(app) + const responseNotFound = await request(app) .get('/config?group=' + new mongoose.Types.ObjectId()) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(404); - await request(app) + const responseInvalidId = await request(app) .get('/config?group=INVALID_ID_VALUE') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(422); + + expect(responseInvalidId.body.error).not.toBeNull(); + expect(responseNotFound.body.error).not.toBeNull(); }); test('CONFIG_SUITE - Should get Config information by Id - resolveComponents as true', async () => { @@ -182,15 +185,97 @@ describe('Testing fetch configuration info', () => { }); test('CONFIG_SUITE - Should not find Config information by Id', async () => { - await request(app) + const responseInvalidId = await request(app) .get('/config/' + 'NOTEXIST') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(422); - await request(app) + const responseNotFound = await request(app) .get('/config/' + new mongoose.Types.ObjectId()) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(404); + + expect(responseInvalidId.body.error).not.toBeNull(); + expect(responseNotFound.body.error).not.toBeNull(); + }); +}); + +describe('Testing fetch configuration by key', () => { + beforeAll(setupDatabase); + + test('CONFIG_SUITE - Should get Config by key', async () => { + const response = await request(app) + .get(`/config/key/${config1Document.key}?domain=Domain`) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(200); + + expect(String(response.body._id)).toEqual(String(config1Document._id)); + expect(response.body.key).toEqual(config1Document.key); + expect(response.body.enabled).toEqual(true); + }); + + test('CONFIG_SUITE - Should get Config by key - case insensitive', async () => { + const response = await request(app) + .get(`/config/key/${config1Document.key.toLowerCase()}?domain=Domain`) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(200); + + expect(response.body.key).toEqual(config1Document.key); + }); + + test('CONFIG_SUITE - Should get Config by key - environment reflected in enabled flag', async () => { + const response = await request(app) + .get(`/config/key/${config1Document.key}?domain=Domain&environment=dev`) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(200); + + // config1Document only has activation for the default environment + // therefore, the request should fall back to the default environment + expect(response.body.enabled).toEqual(true); + }); + + test('CONFIG_SUITE - Should NOT get Config by key - invalid key length', async () => { + const responseInvalidKey = await request(app) + .get('/config/key/AB?domain=Domain') + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(422); + + expect(responseInvalidKey.body.error).not.toBeNull(); + }); + + test('CONFIG_SUITE - Should NOT get Config by key - domain not found', async () => { + const responseDomainNotFound = await request(app) + .get(`/config/key/${config1Document.key}?domain=UNKNOWN_DOMAIN`) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(500); + + expect(responseDomainNotFound.body.error).not.toBeNull(); + }); + + test('CONFIG_SUITE - Should NOT get Config by key - environment not found', async () => { + const responseEnvironmentNotFound = await request(app) + .get(`/config/key/${config1Document.key}?domain=Domain&environment=UNKNOWN_ENV`) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(500); + + expect(responseEnvironmentNotFound.body.error).not.toBeNull(); + }); + + test('CONFIG_SUITE - Should NOT get Config by key - key not found', async () => { + const responseKeyNotFound = await request(app) + .get('/config/key/UNKNOWN_CONFIG_KEY?domain=Domain') + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(500); + + expect(responseKeyNotFound.body.error).not.toBeNull(); + }); + + test('CONFIG_SUITE - Should NOT get Config by key - unauthenticated', async () => { + const responseUnauthenticated = await request(app) + .get(`/config/key/${config1Document.key}?domain=Domain`) + .send().expect(401); + + expect(responseUnauthenticated.body.error).not.toBeNull(); }); }); @@ -198,15 +283,18 @@ describe('Testing configuration deletion', () => { beforeAll(setupDatabase); test('CONFIG_SUITE - Should NOT delete Config - Wrong and bad Id', async () => { - await request(app) + const responseNotFound = await request(app) .delete('/config/' + new mongoose.Types.ObjectId()) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(404); - await request(app) + const responseInvalidId = await request(app) .delete('/config/WRONG_ID_VALUE') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(422); + + expect(responseNotFound.body.error).not.toBeNull(); + expect(responseInvalidId.body.error).not.toBeNull(); }); test('CONFIG_SUITE - Should delete Config', async () => { @@ -284,7 +372,7 @@ describe('Testing update info', () => { }); test('CONFIG_SUITE - Should NOT update Config info', async () => { - await request(app) + const response400 = await request(app) .patch('/config/' + configId1) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ @@ -292,19 +380,23 @@ describe('Testing update info', () => { owner: 'I_SHOULD_NOT_UPDATE_THIS' }).expect(400); - await request(app) + const response404 = await request(app) .patch('/config/' + new mongoose.Types.ObjectId()) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ description: 'New description' }).expect(404); - await request(app) + const response422 = await request(app) .patch('/config/WRONG_ID_VALUE') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ description: 'New description' }).expect(422); + + expect(response422.body.error).not.toBeNull(); + expect(response404.body.error).not.toBeNull(); + expect(response400.body.error).not.toBeNull(); }); test('CONFIG_SUITE - Should update Config environment status - default', async () => { @@ -343,19 +435,22 @@ describe('Testing moving Config from GroupConfig to another', () => { }); test('CONFIG_SUITE - Should NOT move Config to a different GroupConfig - GroupConfig not found', async () => { - await request(app) + const response404 = await request(app) .patch('/config/' + configId1) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ group: new mongoose.Types.ObjectId() }).expect(404); - await request(app) + const response422 = await request(app) .patch('/config/' + configId1) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ group: 'INVALID_VALUE_ID' }).expect(422); + + expect(response404.body.error).not.toBeNull(); + expect(response422.body.error).not.toBeNull(); }); }); @@ -404,19 +499,22 @@ describe('Testing Environment status change', () => { }); test('CONFIG_SUITE - Should NOT update Config environment status - Config not fould', async () => { - await request(app) + const response422 = await request(app) .patch('/config/updateStatus/FAKE_CONFIG') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ default: false }).expect(422); - await request(app) + const response404 = await request(app) .patch('/config/updateStatus/' + new mongoose.Types.ObjectId()) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ default: false }).expect(404); + + expect(response422.body.error).not.toBeNull(); + expect(response404.body.error).not.toBeNull(); }); test('CONFIG_SUITE - Should NOT update Config environment status - Unknown environment name', async () => { @@ -512,27 +610,33 @@ describe('Testing Environment status change', () => { }); test('CONFIG_SUITE - Should NOT list changes by invalid Config Id', async () => { - await request(app) + const response404 = await request(app) .get('/config/history/' + new mongoose.Types.ObjectId()) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(404); - await request(app) + const response422 = await request(app) .get('/config/history/INVALID_ID_VALUE') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(422); + + expect(response404.body.error).not.toBeNull(); + expect(response422.body.error).not.toBeNull(); }); test('CONFIG_SUITE - Should NOT delete history by invalid Config Id', async () => { - await request(app) + const response404 = await request(app) .delete('/config/history/' + new mongoose.Types.ObjectId()) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(404); - await request(app) + const response422 = await request(app) .delete('/config/history/INVALID_ID_VALUE') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(422); + + expect(response404.body.error).not.toBeNull(); + expect(response422.body.error).not.toBeNull(); }); test('CONFIG_SUITE - Should delete history from a Config element', async () => { @@ -688,12 +792,14 @@ describe('Testing component association', () => { }); test('CONFIG_SUITE - Should NOT associate multiple components to a config - Wrong Config Id', async () => { - await request(app) + const response404 = await request(app) .patch('/config/updateComponents/' + new mongoose.Types.ObjectId()) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ components: [ new mongoose.Types.ObjectId() ] }).expect(404); + + expect(response404.body.error).not.toBeNull(); }); test('CONFIG_SUITE - Should NOT associate component to a config - Component not found', async () => { @@ -735,44 +841,52 @@ describe('Testing component association', () => { }); test('CONFIG_SUITE - Should NOT associate component to a config - Config not found', async () => { - await request(app) + const response422 = await request(app) .patch('/config/addComponent/INVALID_ID_VALUE') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ component: new mongoose.Types.ObjectId() }).expect(422); - await request(app) + const response404 = await request(app) .patch('/config/addComponent/' + new mongoose.Types.ObjectId()) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ component: new mongoose.Types.ObjectId() }).expect(404); + + expect(response422.body.error).not.toBeNull(); + expect(response404.body.error).not.toBeNull(); }); test('CONFIG_SUITE - Should NOT desassociate component from a config - Component not found', async () => { - await request(app) + const response404 = await request(app) .patch('/config/removeComponent/' + configId1) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ component: new mongoose.Types.ObjectId() }).expect(404); + + expect(response404.body.error).not.toBeNull(); }); test('CONFIG_SUITE - Should NOT desassociate component from a config - Config not found', async () => { - await request(app) + const response422 = await request(app) .patch('/config/removeComponent/INVALID_ID_VALUE') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ component: new mongoose.Types.ObjectId() }).expect(422); - - await request(app) + + const response404 = await request(app) .patch('/config/removeComponent/' + new mongoose.Types.ObjectId()) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ component: new mongoose.Types.ObjectId() }).expect(404); + + expect(response422.body.error).not.toBeNull(); + expect(response404.body.error).not.toBeNull(); }); test('CONFIG_SUITE - Should desassociate component from a config', async () => { diff --git a/tests/domain.test.js b/tests/domain.test.js index 14f76c26..38a29122 100644 --- a/tests/domain.test.js +++ b/tests/domain.test.js @@ -1,4 +1,5 @@ import mongoose from 'mongoose'; +import jwt from 'jsonwebtoken'; import request from 'supertest'; import app from '../src/app'; import Admin from '../src/models/admin'; @@ -54,12 +55,15 @@ describe('Testing Domain insertion', () => { }); test('DOMAIN_SUITE - Should NOT create a new Domain - Missing required params', async () => { - await request(app) + const response = await request(app) .post('/domain/create') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ description: 'Description of my new Domain' }).expect(422); + + // Response validation + expect(response.body.errors).toBeDefined(); }); test('DOMAIN_SUITE - Should NOT create a new Domain - Already exists', async () => { @@ -151,16 +155,30 @@ describe('Testing fetch Domain info', () => { expect(response.body.admin.name).toBe(adminMasterAccount.name); }); + test('DOMAIN_SUITE - Should NOT get Domain information - expired token (authAny)', async () => { + const expiredToken = jwt.sign({ _id: adminMasterAccountId }, process.env.JWT_SECRET || 'test_secret', { expiresIn: '0s' }); + + const response = await request(app) + .get('/domain') + .set('Authorization', `Bearer ${expiredToken}`) + .send().expect(401); + + expect(response.body.error).toEqual('Token expired.'); + }); + test('DOMAIN_SUITE - Should NOT return Domain information by Id', async () => { - await request(app) + const responseInvalidId = await request(app) .get('/domain/INVALID_ID') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(422); - await request(app) + const responseNotFound = await request(app) .get(`/domain/${new mongoose.Types.ObjectId()}`) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(404); + + expect(responseInvalidId.body.errors).toBeDefined(); + expect(responseNotFound.body.error).toEqual('Domain not found'); }); test('DOMAIN_SUITE - Should delete Domain', async () => { @@ -232,15 +250,18 @@ describe('Testing fetch Domain info', () => { password: adminMasterAccount.password }).expect(200); - await request(app) + const responseNotFound = await request(app) .delete(`/domain/${new mongoose.Types.ObjectId()}`) .set('Authorization', `Bearer ${responseLogin.body.jwt.token}`) .send().expect(404); - await request(app) + const responseInvalidId = await request(app) .delete('/domain/INVALID_DOMAIN_ID') .set('Authorization', `Bearer ${responseLogin.body.jwt.token}`) .send().expect(422); + + expect(responseInvalidId.body.errors).toBeDefined(); + expect(responseNotFound.body.error).toEqual('Domain not found'); }); }); @@ -271,28 +292,33 @@ describe('Testing update Domain info', () => { }); test('DOMAIN_SUITE - Should NOT update Domain info', async () => { - await request(app) + const responseInvalidId = await request(app) .patch('/domain/UNKNOWN_DOMAIN_ID') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ description: 'Description updated' }).expect(422); - await request(app) + const responseNotFound = await request(app) .patch(`/domain/${new mongoose.Types.ObjectId()}`) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ description: 'Description updated' }).expect(404); + + expect(responseInvalidId.body.errors).toBeDefined(); + expect(responseNotFound.body.error).toEqual('Domain not found'); }); test('DOMAIN_SUITE - Should NOT update Domain name', async () => { - await request(app) + const response = await request(app) .patch('/domain/' + domainId) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ name: 'New Domain name' }).expect(400); + + expect(response.body.error).toBeDefined(); }); test('DOMAIN_SUITE - Should update Domain environment status - default', async () => { @@ -313,19 +339,22 @@ describe('Testing update Domain info', () => { }); test('DOMAIN_SUITE - Should NOT update environment status given an unknown Domain ID ', async () => { - await request(app) + const responseInvalidId = await request(app) .patch('/domain/updateStatus/UNKNOWN_DOMAIN_ID') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ default: false }).expect(422); - await request(app) + const responseNotFound = await request(app) .patch(`/domain/updateStatus/${new mongoose.Types.ObjectId()}`) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ default: false }).expect(404); + + expect(responseInvalidId.body.errors).toBeDefined(); + expect(responseNotFound.body.error).toEqual('Domain not found'); }); test('DOMAIN_SUITE - Should NOT update environment status given an unknown environment name', async () => { @@ -340,29 +369,36 @@ describe('Testing update Domain info', () => { }); test('DOMAIN_SUITE - Should NOT read changes on history collection - Invalid Domain Id', async () => { - await request(app) + const response = await request(app) .get('/domain/history/INVALID_ID') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(422); + + expect(response.body.errors).toBeDefined(); }); test('DOMAIN_SUITE - Should NOT read changes on history collection - Domain not found', async () => { - await request(app) + const response = await request(app) .get(`/domain/history/${new mongoose.Types.ObjectId()}`) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(404); + + expect(response.body.error).toEqual('Domain not found'); }); test('DOMAIN_SUITE - Should NOT delete history by invalid Domain Id', async () => { - await request(app) + const responseNotFound = await request(app) .delete(`/domain/history/${new mongoose.Types.ObjectId()}`) .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(404); - await request(app) + const responseInvalidId = await request(app) .delete('/domain/history/INVALID_ID_VALUE') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send().expect(422); + + expect(responseInvalidId.body.errors).toBeDefined(); + expect(responseNotFound.body.error).toEqual('Domain not found'); }); test('DOMAIN_SUITE - Should delete history from a Domain element', async () => { @@ -561,30 +597,36 @@ describe('Testing transfer Domain', () => { beforeAll(setupDatabase); test('DOMAIN_SUITE - Should NOT request Domain to transfer - Domain not found', async () => { - await request(app) + const responseNotFound = await request(app) .patch('/domain/transfer/request') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ domain: new mongoose.Types.ObjectId() }).expect(404); + + expect(responseNotFound.body.error).toEqual('Domain not found'); }); test('DOMAIN_SUITE - Should NOT request Domain to transfer - Invalid Domain ID', async () => { - await request(app) + const responseInvalidId = await request(app) .patch('/domain/transfer/request') .set('Authorization', `Bearer ${adminMasterAccountToken}`) .send({ domain: 'NOT_VALID_ID' }).expect(422); + + expect(responseInvalidId.body.errors).toBeDefined(); }); test('DOMAIN_SUITE - Should NOT request Domain to transfer - Admin is not owner', async () => { - await request(app) + const responseNotOwner = await request(app) .patch('/domain/transfer/request') .set('Authorization', `Bearer ${adminAccountToken}`) .send({ domain: domainId }).expect(404); + + expect(responseNotOwner.body.error).toEqual('Domain not found'); }); test('DOMAIN_SUITE - Should request/cancel Domain to transfer', async () => { @@ -613,30 +655,36 @@ describe('Testing transfer Domain', () => { }); test('DOMAIN_SUITE - Should NOT accept Domain to transfer - Domain not found', async () => { - await request(app) + const responseNotFound = await request(app) .patch('/domain/transfer/accept') .set('Authorization', `Bearer ${adminAccountToken}`) .send({ domain: new mongoose.Types.ObjectId() }).expect(404); + + expect(responseNotFound.body.error).toEqual('Domain not found'); }); test('DOMAIN_SUITE - Should NOT accept Domain to transfer - Invalid Domain ID', async () => { - await request(app) + const responseInvalidId = await request(app) .patch('/domain/transfer/accept') .set('Authorization', `Bearer ${adminAccountToken}`) .send({ domain: 'NOT_VALID_ID' }).expect(422); + + expect(responseInvalidId.body.errors).toBeDefined(); }); test('DOMAIN_SUITE - Should NOT accept Domain to transfer - Domain not flagged to be transfered', async () => { - await request(app) + const responseNotFlagged = await request(app) .patch('/domain/transfer/accept') .set('Authorization', `Bearer ${adminAccountToken}`) .send({ domain: domainId }).expect(404); + + expect(responseNotFlagged.body.error).toEqual('Domain not found'); }); test('DOMAIN_SUITE - Should NOT accept Domain to transfer - Domain quota exceeded', async () => { diff --git a/tests/fixtures/db_api.js b/tests/fixtures/db_api.js index 05d5ab89..adcbb412 100644 --- a/tests/fixtures/db_api.js +++ b/tests/fixtures/db_api.js @@ -15,6 +15,7 @@ import { Metric } from '../../src/models/metric'; import { EnvType, Environment } from '../../src/models/environment'; import { ConfigStrategy, StrategiesType, OperationsType } from '../../src/models/config-strategy'; import { SlackTicket } from '../../src/models/slack_ticket'; +import { OAuthAuthorizationCode } from '../../src/models/oauth-authorization-code'; import Slack from '../../src/models/slack'; import { EncryptionSalts } from '../../src/models/common'; @@ -269,6 +270,7 @@ export const setupDatabase = async () => { await Team.deleteMany().exec(); await TeamInvite.deleteMany().exec(); await Permission.deleteMany().exec(); + await OAuthAuthorizationCode.deleteMany().exec(); adminMasterAccount.token = Admin.extractTokenPart(adminMasterAccountToken); await new Admin(adminMasterAccount).save(); diff --git a/tests/oauth.test.js b/tests/oauth.test.js new file mode 100644 index 00000000..d592063c --- /dev/null +++ b/tests/oauth.test.js @@ -0,0 +1,914 @@ +import crypto from 'node:crypto'; +import mongoose from 'mongoose'; +import request from 'supertest'; +import app from '../src/app'; +import { OAuthClient } from '../src/models/oauth-client'; +import { OAuthAuthorizationCode } from '../src/models/oauth-authorization-code'; +import { OAuthToken } from '../src/models/oauth-token'; +import Admin from '../src/models/admin'; +import { + setupDatabase, + adminMasterAccountToken, + adminMasterAccountId, + domainId, + config1Document +} from './fixtures/db_api'; + +const REDIRECT_URI = 'http://127.0.0.1:8945/callback'; + +function buildPkcePair() { + const codeVerifier = crypto.randomBytes(32).toString('base64url'); + const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); + return { codeVerifier, codeChallenge }; +} + +async function registerClient(overrides = {}) { + const response = await request(app) + .post('/oauth/register') + .send({ + client_name: 'MCP Test Client', + redirect_uris: [REDIRECT_URI], + ...overrides + }).expect(201); + + return response.body; +} + +async function authorizeAndExchange({ client_id }) { + const { codeVerifier, codeChallenge } = buildPkcePair(); + const state = 'state-123'; + + const authorizeResponse = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id, + redirect_uri: REDIRECT_URI, + scope: 'config:read', + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + consent: 'true' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(302); + + const location = new URL(authorizeResponse.headers.location); + const code = location.searchParams.get('code'); + + const tokenResponse = await request(app) + .post('/oauth/token') + .send({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id, + code_verifier: codeVerifier + }).expect(200); + + return { code, state, token: tokenResponse.body }; +} + +afterAll(async () => { + await new Promise(resolve => setTimeout(resolve, 1000)); + await mongoose.disconnect(); +}); + +describe('Testing OAuth discovery metadata', () => { + beforeAll(setupDatabase); + + test('OAUTH_SUITE - Should return authorization server metadata', async () => { + const response = await request(app) + .get('/.well-known/oauth-authorization-server') + .send().expect(200); + + expect(response.body).toMatchObject({ + authorization_endpoint: expect.stringContaining('/oauth/consent'), + token_endpoint: expect.stringContaining('/oauth/token'), + registration_endpoint: expect.stringContaining('/oauth/register'), + scopes_supported: ['config:read'], + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'] + }); + }); + + test('OAUTH_SUITE - Should prefer SWITCHER_MANAGEMENT_URL over SAML_REDIRECT_ENDPOINT_URL', async () => { + //given + process.env.SWITCHER_MANAGEMENT_URL = 'http://localhost:4201'; + + //test + const response = await request(app) + .get('/.well-known/oauth-authorization-server') + .send().expect(200); + + expect(response.body.authorization_endpoint).toBe('http://localhost:4201/oauth/consent'); + + //teardown + delete process.env.SWITCHER_MANAGEMENT_URL; + }); + + test('OAUTH_SUITE - Should fall back to the API authorize endpoint when no management URL is configured', async () => { + //given + const originalSamlRedirectEndpointUrl = process.env.SAML_REDIRECT_ENDPOINT_URL; + delete process.env.SAML_REDIRECT_ENDPOINT_URL; + + //test + const response = await request(app) + .get('/.well-known/oauth-authorization-server') + .send().expect(200); + + expect(response.body.authorization_endpoint).toBe(`${response.body.issuer}/oauth/authorize`); + + //teardown + process.env.SAML_REDIRECT_ENDPOINT_URL = originalSamlRedirectEndpointUrl; + }); + + test('OAUTH_SUITE - Should return protected resource metadata', async () => { + const response = await request(app) + .get('/.well-known/oauth-protected-resource') + .send().expect(200); + + expect(response.body).toMatchObject({ + resource: response.body.issuer, + authorization_servers: [response.body.issuer], + bearer_methods_supported: ['header'] + }); + }); +}); + +describe('Testing OAuth Dynamic Client Registration', () => { + beforeAll(setupDatabase); + + test('OAUTH_SUITE - Should register a new client', async () => { + const client = await registerClient(); + + expect(client.client_id).toEqual(expect.any(String)); + expect(client.client_name).toEqual('MCP Test Client'); + expect(client.redirect_uris).toEqual([REDIRECT_URI]); + expect(client.grant_types).toEqual(['authorization_code', 'refresh_token']); + expect(client.response_types).toEqual(['code']); + expect(client.token_endpoint_auth_method).toEqual('none'); + expect(client.scope).toEqual('config:read'); + expect(client.client_id_issued_at).toEqual(expect.any(Number)); + + const stored = await OAuthClient.findOne({ client_id: client.client_id }).exec(); + expect(stored).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should NOT register a client - missing redirect_uris', async () => { + const response = await request(app) + .post('/oauth/register') + .send({ client_name: 'No Redirect' }).expect(422); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should NOT register a client - invalid token_endpoint_auth_method', async () => { + const response = await request(app) + .post('/oauth/register') + .send({ + redirect_uris: [REDIRECT_URI], + token_endpoint_auth_method: 'client_secret_basic' + }).expect(400); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should NOT register a client - unsupported grant_types', async () => { + const response = await request(app) + .post('/oauth/register') + .send({ + redirect_uris: [REDIRECT_URI], + grant_types: ['implicit'] + }).expect(400); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should NOT register a client - unsupported response_types', async () => { + const response = await request(app) + .post('/oauth/register') + .send({ + redirect_uris: [REDIRECT_URI], + response_types: ['token'] + }).expect(400); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should NOT register a client - invalid scope', async () => { + const response = await request(app) + .post('/oauth/register') + .send({ + redirect_uris: [REDIRECT_URI], + scope: 'config:write' + }).expect(400); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should NOT register a client - invalid redirect_uri', async () => { + const response = await request(app) + .post('/oauth/register') + .send({ + redirect_uris: ['ftp://not-allowed.example.com/callback'] + }).expect(400); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should NOT register a client - malformed redirect_uri', async () => { + const response = await request(app) + .post('/oauth/register') + .send({ + redirect_uris: ['not-a-valid-url'] + }).expect(400); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should NOT register a client - redirect_uri containing a fragment', async () => { + const response = await request(app) + .post('/oauth/register') + .send({ + redirect_uris: ['https://example.com/callback#fragment'] + }).expect(400); + + expect(response.body.error).not.toBeNull(); + }); +}); + +describe('Testing OAuth Authorization Code flow', () => { + let clientId; + + beforeAll(async () => { + await setupDatabase(); + const client = await registerClient(); + clientId = client.client_id; + }); + + test('OAUTH_SUITE - Should NOT authorize - unauthenticated', async () => { + const response = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + code_challenge: 'challenge', + code_challenge_method: 'S256' + }) + .send().expect(401); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should NOT authorize - invalid client_id', async () => { + const response = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: 'UNKNOWN_CLIENT_ID', + redirect_uri: REDIRECT_URI, + code_challenge: 'challenge', + code_challenge_method: 'S256' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(400); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should NOT authorize - redirect_uri not registered', async () => { + const response = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: clientId, + redirect_uri: 'http://127.0.0.1:9999/other-callback', + code_challenge: 'challenge', + code_challenge_method: 'S256' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(400); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should redirect with access_denied - consent refused', async () => { + const response = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + code_challenge: 'challenge', + code_challenge_method: 'S256', + state: 'xyz', + consent: 'false' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(302); + + const location = new URL(response.headers.location); + expect(location.searchParams.get('error')).toEqual('access_denied'); + expect(location.searchParams.get('state')).toEqual('xyz'); + }); + + test('OAUTH_SUITE - Should redirect with invalid_request - missing code_challenge', async () => { + const response = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + consent: 'true' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(302); + + const location = new URL(response.headers.location); + expect(location.searchParams.get('error')).toEqual('invalid_request'); + }); + + test('OAUTH_SUITE - Should redirect with invalid_request - unsupported response_type', async () => { + const response = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'token', + client_id: clientId, + redirect_uri: REDIRECT_URI, + code_challenge: 'challenge', + code_challenge_method: 'S256', + consent: 'true' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(302); + + const location = new URL(response.headers.location); + expect(location.searchParams.get('error')).toEqual('invalid_request'); + }); + + test('OAUTH_SUITE - Should redirect with invalid_request - unsupported code_challenge_method', async () => { + const response = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + code_challenge: 'challenge', + code_challenge_method: 'plain', + consent: 'true' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(302); + + const location = new URL(response.headers.location); + expect(location.searchParams.get('error')).toEqual('invalid_request'); + }); + + test('OAUTH_SUITE - Should redirect with an authorization code - consent granted', async () => { + const { codeChallenge } = buildPkcePair(); + + const response = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + scope: 'config:read', + state: 'state-abc', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + consent: 'true' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(302); + + const location = new URL(response.headers.location); + expect(location.searchParams.get('code')).toEqual(expect.any(String)); + expect(location.searchParams.get('state')).toEqual('state-abc'); + + const storedCode = await OAuthAuthorizationCode.findOne({ + code: location.searchParams.get('code') + }).exec(); + expect(storedCode).not.toBeNull(); + expect(String(storedCode.admin)).toEqual(String(adminMasterAccountId)); + }); +}); + +describe('Testing OAuth Authorization Code flow - JSON response mode', () => { + let clientId; + + beforeAll(async () => { + await setupDatabase(); + const client = await registerClient(); + clientId = client.client_id; + }); + + test('OAUTH_SUITE - Should return the redirect_uri as JSON instead of a 302 - consent granted', async () => { + const { codeChallenge } = buildPkcePair(); + + const response = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + scope: 'config:read', + state: 'state-json-abc', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + consent: 'true' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .set('Accept', 'application/json') + .send().expect(200); + + expect(response.body.redirect_uri).toEqual(expect.any(String)); + + const location = new URL(response.body.redirect_uri); + expect(location.searchParams.get('code')).toEqual(expect.any(String)); + expect(location.searchParams.get('state')).toEqual('state-json-abc'); + + const storedCode = await OAuthAuthorizationCode.findOne({ + code: location.searchParams.get('code') + }).exec(); + expect(storedCode).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should return the redirect_uri as JSON instead of a 302 - consent refused', async () => { + const response = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + code_challenge: 'challenge', + code_challenge_method: 'S256', + state: 'state-json-denied', + consent: 'false' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .set('Accept', 'application/json') + .send().expect(200); + + const location = new URL(response.body.redirect_uri); + expect(location.searchParams.get('error')).toEqual('access_denied'); + expect(location.searchParams.get('state')).toEqual('state-json-denied'); + }); +}); + +describe('Testing OAuth token exchange', () => { + let clientId; + + beforeAll(async () => { + await setupDatabase(); + const client = await registerClient(); + clientId = client.client_id; + }); + + test('OAUTH_SUITE - Should exchange an authorization code for a token pair', async () => { + const { token } = await authorizeAndExchange({ client_id: clientId }); + + expect(token.access_token).toEqual(expect.any(String)); + expect(token.refresh_token).toEqual(expect.any(String)); + expect(token.token_type).toEqual('Bearer'); + expect(token.expires_in).toBeGreaterThan(0); + expect(token.scope).toEqual('config:read'); + }); + + test('OAUTH_SUITE - Should NOT exchange - unsupported grant_type', async () => { + const response = await request(app) + .post('/oauth/token') + .send({ + grant_type: 'client_credentials', + client_id: clientId + }).expect(400); + + expect(response.body.error).toEqual('unsupported_grant_type'); + }); + + test('OAUTH_SUITE - Should NOT exchange - missing required fields', async () => { + const response = await request(app) + .post('/oauth/token') + .send({ grant_type: 'authorization_code' }).expect(422); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should NOT exchange - invalid client_id', async () => { + const response = await request(app) + .post('/oauth/token') + .send({ + grant_type: 'authorization_code', + code: 'anything', + redirect_uri: REDIRECT_URI, + client_id: 'UNKNOWN_CLIENT_ID', + code_verifier: 'verifier' + }).expect(400); + + expect(response.body.error).toEqual('invalid_grant'); + }); + + test('OAUTH_SUITE - Should NOT exchange - invalid code_verifier', async () => { + const { codeChallenge } = buildPkcePair(); + const state = 'state-verifier'; + + const authorizeResponse = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + consent: 'true' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(302); + + const location = new URL(authorizeResponse.headers.location); + const code = location.searchParams.get('code'); + + const response = await request(app) + .post('/oauth/token') + .send({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id: clientId, + code_verifier: 'wrong-verifier' + }).expect(400); + + expect(response.body.error).toEqual('invalid_grant'); + }); + + test('OAUTH_SUITE - Should NOT exchange - authorization code already used', async () => { + const { code, codeVerifier } = await (async () => { + const pair = buildPkcePair(); + const authorizeResponse = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + state: 'reuse', + code_challenge: pair.codeChallenge, + code_challenge_method: 'S256', + consent: 'true' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(302); + + const location = new URL(authorizeResponse.headers.location); + return { code: location.searchParams.get('code'), codeVerifier: pair.codeVerifier }; + })(); + + await request(app) + .post('/oauth/token') + .send({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id: clientId, + code_verifier: codeVerifier + }).expect(200); + + const response = await request(app) + .post('/oauth/token') + .send({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id: clientId, + code_verifier: codeVerifier + }).expect(400); + + expect(response.body.error).toEqual('invalid_grant'); + }); + + test('OAUTH_SUITE - Should NOT exchange - missing code_verifier', async () => { + const { codeChallenge } = buildPkcePair(); + + const authorizeResponse = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + state: 'no-verifier', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + consent: 'true' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(302); + + const location = new URL(authorizeResponse.headers.location); + const code = location.searchParams.get('code'); + + const response = await request(app) + .post('/oauth/token') + .send({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id: clientId + }).expect(400); + + expect(response.body.error).toEqual('invalid_grant'); + }); + + test('OAUTH_SUITE - Should NOT exchange - redirect_uri mismatch from authorization', async () => { + const REDIRECT_URI_2 = 'http://127.0.0.1:8945/callback2'; + const client = await registerClient({ redirect_uris: [REDIRECT_URI, REDIRECT_URI_2] }); + const { codeChallenge, codeVerifier } = buildPkcePair(); + + const authorizeResponse = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: client.client_id, + redirect_uri: REDIRECT_URI, + state: 'mismatch', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + consent: 'true' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(302); + + const location = new URL(authorizeResponse.headers.location); + const code = location.searchParams.get('code'); + + const response = await request(app) + .post('/oauth/token') + .send({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI_2, + client_id: client.client_id, + code_verifier: codeVerifier + }).expect(400); + + expect(response.body.error).toEqual('invalid_grant'); + }); + + test('OAUTH_SUITE - Should NOT exchange - authorization code expired', async () => { + const expiredCode = new OAuthAuthorizationCode({ + code: 'expired-code-value', + client_id: clientId, + admin: adminMasterAccountId, + redirect_uri: REDIRECT_URI, + scope: 'config:read', + code_challenge: 'challenge', + code_challenge_method: 'S256', + expiresAt: new Date(Date.now() - 1000) + }); + await expiredCode.save(); + + const response = await request(app) + .post('/oauth/token') + .send({ + grant_type: 'authorization_code', + code: 'expired-code-value', + redirect_uri: REDIRECT_URI, + client_id: clientId, + code_verifier: 'verifier' + }).expect(400); + + expect(response.body.error).toEqual('invalid_grant'); + }); + + test('OAUTH_SUITE - Should refresh an access token and rotate the refresh token', async () => { + const { token } = await authorizeAndExchange({ client_id: clientId }); + + const refreshResponse = await request(app) + .post('/oauth/token') + .send({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: token.refresh_token + }).expect(200); + + expect(refreshResponse.body.access_token).toEqual(expect.any(String)); + expect(refreshResponse.body.access_token).not.toEqual(token.access_token); + expect(refreshResponse.body.refresh_token).not.toEqual(token.refresh_token); + + // old refresh token must no longer be usable (rotation) + const reuseResponse = await request(app) + .post('/oauth/token') + .send({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: token.refresh_token + }).expect(400); + + expect(reuseResponse.body.error).toEqual('invalid_grant'); + }); + + test('OAUTH_SUITE - Should NOT refresh - invalid refresh_token', async () => { + const response = await request(app) + .post('/oauth/token') + .send({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: 'not-a-real-refresh-token' + }).expect(400); + + expect(response.body.error).toEqual('invalid_grant'); + }); + + test('OAUTH_SUITE - Should NOT refresh - missing refresh_token', async () => { + const response = await request(app) + .post('/oauth/token') + .send({ + grant_type: 'refresh_token', + client_id: clientId + }).expect(400); + + expect(response.body.error).toEqual('invalid_grant'); + }); + + test('OAUTH_SUITE - Should NOT refresh - refresh_token expired', async () => { + const { token } = await authorizeAndExchange({ client_id: clientId }); + + const expiredToken = await OAuthToken.findOne({ access_token: token.access_token }).exec(); + expiredToken.refreshTokenExpiresAt = new Date(Date.now() - 1000); + await expiredToken.save(); + + const response = await request(app) + .post('/oauth/token') + .send({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: token.refresh_token + }).expect(400); + + expect(response.body.error).toEqual('invalid_grant'); + }); +}); + +describe('Testing protected resource access using an OAuth access token', () => { + let clientId; + let accessToken; + + beforeAll(async () => { + await setupDatabase(); + const client = await registerClient(); + clientId = client.client_id; + + const { token } = await authorizeAndExchange({ client_id: clientId }); + accessToken = token.access_token; + }); + + test('OAUTH_SUITE - Should list domains using an OAuth access token', async () => { + const response = await request(app) + .get('/domain') + .set('Authorization', `Bearer ${accessToken}`) + .send().expect(200); + + expect(response.body.length).toEqual(1); + expect(String(response.body[0]._id)).toEqual(String(domainId)); + }); + + test('OAUTH_SUITE - Should list domain collaborations using an OAuth access token', async () => { + const response = await request(app) + .get('/domain/collaboration') + .set('Authorization', `Bearer ${accessToken}`) + .send().expect(200); + + expect(response.body.length).toEqual(0); + }); + + test('OAUTH_SUITE - Should list environments using an OAuth access token', async () => { + const response = await request(app) + .get(`/environment?domain=${domainId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send().expect(200); + + expect(response.body.length).toBeGreaterThan(0); + }); + + test('OAUTH_SUITE - Should get a Config by key using an OAuth access token', async () => { + const response = await request(app) + .get(`/config/key/${config1Document.key}?domain=Domain`) + .set('Authorization', `Bearer ${accessToken}`) + .send().expect(200); + + expect(response.body.key).toEqual(config1Document.key); + expect(response.body.enabled).toEqual(true); + }); + + test('OAUTH_SUITE - Should NOT access protected resources with an invalid access token', async () => { + const response = await request(app) + .get('/domain') + .set('Authorization', 'Bearer invalid-access-token') + .send().expect(401); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should NOT access protected resources when admin is not active', async () => { + const inactiveAdminToken = new OAuthToken({ + access_token: crypto.randomBytes(32).toString('hex'), + refresh_token: crypto.randomBytes(32).toString('hex'), + client_id: clientId, + admin: adminMasterAccountId, + scope: 'config:read', + accessTokenExpiresAt: new Date(Date.now() + 60000), + refreshTokenExpiresAt: new Date(Date.now() + 60000) + }); + await inactiveAdminToken.save(); + await Admin.findByIdAndUpdate(adminMasterAccountId, { active: false }).exec(); + + const response = await request(app) + .get('/domain') + .set('Authorization', `Bearer ${inactiveAdminToken.access_token}`) + .send().expect(401); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should NOT access protected resources with an expired access token', async () => { + const expiredToken = await OAuthToken.findOne({ access_token: accessToken }).exec(); + expiredToken.accessTokenExpiresAt = new Date(Date.now() - 1000); + await expiredToken.save(); + + const response = await request(app) + .get('/domain') + .set('Authorization', `Bearer ${accessToken}`) + .send().expect(401); + + expect(response.body.error).not.toBeNull(); + }); +}); + +describe('Testing OAuth authorized apps management', () => { + let clientId; + let clientName; + + beforeAll(async () => { + await setupDatabase(); + const client = await registerClient({ client_name: 'Authorized Apps Client' }); + clientId = client.client_id; + clientName = client.client_name; + + await authorizeAndExchange({ client_id: clientId }); + }); + + test('OAUTH_SUITE - Should list authorized apps', async () => { + const response = await request(app) + .get('/oauth/authorized') + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(200); + + expect(response.body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + client_id: clientId, + client_name: clientName, + scope: 'config:read' + }) + ]) + ); + }); + + test('OAUTH_SUITE - Should NOT list authorized apps - unauthenticated', async () => { + const response = await request(app) + .get('/oauth/authorized') + .send().expect(401); + + expect(response.body.error).not.toBeNull(); + }); + + test('OAUTH_SUITE - Should revoke an authorized app', async () => { + await request(app) + .delete(`/oauth/authorized/${clientId}`) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(200); + + const response = await request(app) + .get('/oauth/authorized') + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(200); + + expect(response.body).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ client_id: clientId }) + ]) + ); + }); + + test('OAUTH_SUITE - Should NOT revoke an authorized app - not found', async () => { + const response = await request(app) + .delete('/oauth/authorized/UNKNOWN_CLIENT_ID') + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(400); + + expect(response.body.error).not.toBeNull(); + }); +}); diff --git a/tests/services/oauth.test.js b/tests/services/oauth.test.js new file mode 100644 index 00000000..197aadf3 --- /dev/null +++ b/tests/services/oauth.test.js @@ -0,0 +1,108 @@ +import '../../src/db/mongoose'; + +import mongoose from 'mongoose'; +import sinon from 'sinon'; +import { + registerClient, + createAuthorizationCode, + exchangeAuthorizationCode +} from '../../src/services/oauth'; +import { OAuthAuthorizationCode } from '../../src/models/oauth-authorization-code'; +import { setupDatabase, adminMasterAccountId } from '../fixtures/db_api'; + +const REDIRECT_URI = 'https://example.com/callback'; + +afterAll(async () => { + await new Promise(resolve => setTimeout(resolve, 1000)); + await mongoose.disconnect(); +}); + +describe('Testing OAuth service - registerClient', () => { + beforeEach(setupDatabase); + + // This branch is guarded by router-level validation (redirect_uris must have + // at least 1 entry) and is unreachable via the API, so it is exercised here directly. + test('OAUTH_SERVICE - Should NOT register a client - empty redirect_uris', async () => { + await expect(registerClient({ redirect_uris: [] })) + .rejects.toThrow('redirect_uris is required'); + }); +}); + +describe('Testing OAuth service - createAuthorizationCode', () => { + let client; + + beforeEach(async () => { + await setupDatabase(); + client = await registerClient({ + client_name: 'Service Test Client', + redirect_uris: [REDIRECT_URI] + }); + }); + + // The router pre-validates code_challenge/code_challenge_method before calling + // this service function, so these branches are unreachable via the API. + test('OAUTH_SERVICE - Should NOT create authorization code - missing code_challenge', async () => { + await expect(createAuthorizationCode({ + client_id: client.client_id, + admin: adminMasterAccountId, + redirect_uri: REDIRECT_URI, + code_challenge_method: 'S256' + })).rejects.toThrow('Invalid code_challenge'); + }); + + test('OAUTH_SERVICE - Should NOT create authorization code - unsupported code_challenge_method', async () => { + await expect(createAuthorizationCode({ + client_id: client.client_id, + admin: adminMasterAccountId, + redirect_uri: REDIRECT_URI, + code_challenge: 'challenge-value', + code_challenge_method: 'plain' + })).rejects.toThrow('Invalid code_challenge_method'); + }); +}); + +describe('Testing OAuth service - exchangeAuthorizationCode', () => { + let client; + let findOneStub; + + beforeEach(async () => { + await setupDatabase(); + client = await registerClient({ + client_name: 'Service Test Client', + redirect_uris: [REDIRECT_URI] + }); + }); + + afterEach(() => { + findOneStub?.restore(); + }); + + // The OAuthAuthorizationCode schema enum only allows 'S256' for + // code_challenge_method, so a stored code with any other value can only occur + // from corrupted/legacy data - it can never be persisted through the model. + // This branch is stubbed at the query layer to exercise that defensive check, + // since it is otherwise unreachable via the API or a real database record. + test('OAUTH_SERVICE - Should NOT exchange - stored code has unsupported code_challenge_method', async () => { + const fakeAuthorizationCode = { + used: false, + redirect_uri: REDIRECT_URI, + code_challenge: 'challenge-value', + code_challenge_method: 'plain', + scope: 'config:read', + admin: adminMasterAccountId, + expiresAt: new Date(Date.now() + 60000), + save: sinon.stub().resolves() + }; + + findOneStub = sinon.stub(OAuthAuthorizationCode, 'findOne').returns({ + exec: sinon.stub().resolves(fakeAuthorizationCode) + }); + + await expect(exchangeAuthorizationCode({ + client_id: client.client_id, + code: 'tampered-method-code', + redirect_uri: REDIRECT_URI, + code_verifier: 'verifier' + })).rejects.toThrow('Invalid code_challenge_method'); + }); +}); From 55c8b293c28ec4b96fc689bae110a15d287c7094 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:21:39 -0700 Subject: [PATCH 2/2] chore: fixes async promises and loop awaits --- src/services/config.js | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/services/config.js b/src/services/config.js index 4654f53e..1b1abe30 100644 --- a/src/services/config.js +++ b/src/services/config.js @@ -90,7 +90,7 @@ export async function getTotalConfigsByDomainId(domain) { } export async function populateAdmin(configs) { - for (const config of configs) { + for await (const config of configs) { await config.populate({ path: 'admin', select: 'name' }); } } @@ -126,7 +126,7 @@ export async function createConfig(args, admin) { // creates config await config.save(); - updateDomainVersion(config.domain); + await updateDomainVersion(config.domain); // resets permission cache permissionCache.permissionReset(config.domain, ActionTypes.ALL, RouterTypes.CONFIG, config.group); @@ -139,7 +139,7 @@ export async function deleteConfig(id, admin) { config = await verifyOwnership(admin, config, config.domain, ActionTypes.DELETE, RouterTypes.CONFIG); await config.deleteOne(); - updateDomainVersion(config.domain); + await updateDomainVersion(config.domain); // resets permission cache permissionCache.permissionReset(config.domain, ActionTypes.ALL, RouterTypes.CONFIG, config.group); @@ -174,7 +174,7 @@ export async function updateConfig(id, args, admin) { updates.forEach((update) => config[update] = args[update]); await config.save(); - updateDomainVersion(config.domain); + await updateDomainVersion(config.domain); return config; } @@ -213,9 +213,13 @@ export async function updateConfigRelay(id, args, admin) { RouterTypes.CONFIG, false, Object.keys(args.activated)[0]); config.updatedBy = admin.email; - for (const update of Object.keys(args)) { + const updates = Object.keys(args); + await Promise.all(updates + .filter((update) => config.relay[update] && 'activated endpoint auth_token'.includes(update)) + .map((update) => checkEnvironmentStatusChange(args, config.domain, args[update]))); + + for (const update of updates) { if (config.relay[update] && 'activated endpoint auth_token'.includes(update)) { - await checkEnvironmentStatusChange(args, config.domain, args[update]); Object.keys(args[update]).forEach((map) => config.relay[update].set(map, args[update][map])); } else { @@ -224,7 +228,7 @@ export async function updateConfigRelay(id, args, admin) { } await config.save(); - updateDomainVersion(config.domain); + await updateDomainVersion(config.domain); return config; } @@ -239,7 +243,7 @@ export async function updateConfigStatus(id, args, admin) { updates.forEach((update) => config.activated.set(update, args[update])); await config.save(); - updateDomainVersion(config.domain); + await updateDomainVersion(config.domain); return config; } @@ -250,7 +254,7 @@ export async function removeConfigStatusEnv(id, env, admin) { RouterTypes.CONFIG, false, env); config.updatedBy = admin.email; - updateDomainVersion(config.domain); + await updateDomainVersion(config.domain); return removeConfigStatus(config, env); } @@ -287,7 +291,7 @@ export async function addComponent(id, args, admin) { config.updatedBy = admin.email; config.components.push(component._id); await config.save(); - updateDomainVersion(config.domain); + await updateDomainVersion(config.domain); return config; } @@ -300,7 +304,7 @@ export async function removeComponent(id, args, admin) { const indexComponent = config.components.indexOf(args.component); config.components.splice(indexComponent, 1); await config.save(); - updateDomainVersion(config.domain); + await updateDomainVersion(config.domain); return config; } @@ -316,7 +320,7 @@ export async function updateComponent(id, args, admin) { config.updatedBy = admin.email; config.components = args.components; await config.save(); - updateDomainVersion(config.domain); + await updateDomainVersion(config.domain); return config; } @@ -338,7 +342,7 @@ export async function removeRelay(id, env, admin) { } await config.save(); - updateDomainVersion(config.domain); + await updateDomainVersion(config.domain); } return config;