diff --git a/.env-cmdrc-template b/.env-cmdrc-template index 2a459d8..6c30610 100644 --- a/.env-cmdrc-template +++ b/.env-cmdrc-template @@ -21,6 +21,16 @@ "GOOGLE_RECAPTCHA_SECRET": "[GOOGLE_RECAPTCHA_SECRET]", "GOOGLE_SKIP_AUTH": true, + "SAML_ENTRY_POINT": "https:///sso/saml", + "SAML_ISSUER": "switcher-api", + "SAML_CALLBACK_ENDPOINT_URL": "http://localhost:3000", + "SAML_REDIRECT_ENDPOINT_URL": "http://localhost:4200", + "SAML_CERT": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K", + "SAML_PRIVATE_KEY": "", + "SAML_IDENTIFIER_FORMAT": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "SAML_ACCEPTED_CLOCK_SKEW_MS": 10000, + "SESSION_SECRET": "SESSION_SECRET", + "SWITCHER_API_LOGGER": true, "SWITCHER_API_LOGGER_LEVEL": "debug", "SWITCHER_API_ENABLE": false, @@ -50,6 +60,12 @@ "GOOGLE_RECAPTCHA_SECRET": "[GOOGLE_RECAPTCHA_SECRET]", "GOOGLE_SKIP_AUTH": false, + "SAML_ENTRY_POINT": "http://localhost:3000/sso/saml", + "SAML_CALLBACK_ENDPOINT_URL": "http://localhost:3000", + "SAML_REDIRECT_ENDPOINT_URL": "http://localhost:4200", + "SAML_CERT": "SAML_CERT", + "SESSION_SECRET": "SESSION_SECRET", + "SWITCHER_API_LOGGER": false, "SWITCHER_API_LOGGER_LEVEL": "debug", "SWITCHER_API_ENABLE": false, diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 4434993..fae6e57 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -51,6 +51,11 @@ jobs: RELAY_BYPASS_VERIFICATION: true PERMISSION_CACHE_ACTIVATED: true METRICS_MAX_PAGE: 50 + SAML_ENTRY_POINT: http://localhost:3000/sso/saml + SAML_CALLBACK_ENDPOINT_URL: http://localhost:3000 + SAML_REDIRECT_ENDPOINT_URL: http://localhost:4200 + SAML_CERT: SAML_CERT + SESSION_SECRET: SESSION_SECRET SWITCHER_API_ENABLE: false SWITCHER_API_LOGGER: false diff --git a/.github/workflows/re-release.yml b/.github/workflows/re-release.yml index 4452d77..beedff4 100644 --- a/.github/workflows/re-release.yml +++ b/.github/workflows/re-release.yml @@ -53,6 +53,11 @@ jobs: RELAY_BYPASS_VERIFICATION: true PERMISSION_CACHE_ACTIVATED: true METRICS_MAX_PAGE: 50 + SAML_ENTRY_POINT: http://localhost:3000/sso/saml + SAML_CALLBACK_ENDPOINT_URL: http://localhost:3000 + SAML_REDIRECT_ENDPOINT_URL: http://localhost:4200 + SAML_CERT: SAML_CERT + SESSION_SECRET: SESSION_SECRET SWITCHER_API_ENABLE: false SWITCHER_API_LOGGER: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1003038..38ab0e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,6 +48,11 @@ jobs: RELAY_BYPASS_VERIFICATION: true PERMISSION_CACHE_ACTIVATED: true METRICS_MAX_PAGE: 50 + SAML_ENTRY_POINT: http://localhost:3000/sso/saml + SAML_CALLBACK_ENDPOINT_URL: http://localhost:3000 + SAML_REDIRECT_ENDPOINT_URL: http://localhost:4200 + SAML_CERT: SAML_CERT + SESSION_SECRET: SESSION_SECRET SWITCHER_API_ENABLE: false SWITCHER_API_LOGGER: false diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 6950c33..d0cfdea 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -66,6 +66,11 @@ jobs: RELAY_BYPASS_VERIFICATION: true PERMISSION_CACHE_ACTIVATED: true METRICS_MAX_PAGE: 50 + SAML_ENTRY_POINT: http://localhost:3000/sso/saml + SAML_CALLBACK_ENDPOINT_URL: http://localhost:3000 + SAML_REDIRECT_ENDPOINT_URL: http://localhost:4200 + SAML_CERT: SAML_CERT + SESSION_SECRET: SESSION_SECRET SWITCHER_API_ENABLE: false SWITCHER_API_LOGGER: false diff --git a/README.md b/README.md index ab85c9a..cccb228 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,7 @@ Main features: ### Auth Providers -Switcher API supports multiple auth providers such as email/password-based authentication or GitHub, Bitbucket OAuth. - -Follow the steps below to set up your OAuth App in GitHub and Bitbucket. +Switcher API supports multiple auth providers such as email/password-based authentication, SAML 2.0 for Single Sign-On (SSO), or GitHub/Bitbucket OAuth. #### GitHub OAuth App setup @@ -79,6 +77,29 @@ Follow the steps below to set up your OAuth App in GitHub and Bitbucket. - BIT_OAUTH_CLIENT_SECRET=your_client_secret 8. Update Switcher Management BITBUCKET_CLIENTID environment variable with your_client_id +#### SSO with SAML 2.0 setup + +1. Obtain the following information from your Identity Provider (IdP): + - Entry Point URL + - X.509 Certificate + - (Optional) Private Key + +2. Update your .env-cmdrc file or ConfigMap/Secret in Kubernetes with the following variables: + - SAML_ENTRY_POINT=your_idp_entry_point_url + - SAML_ISSUER=your_issuer + - SAML_CALLBACK_ENDPOINT_URL=service_provider_callback_endpoint_url + - SAML_REDIRECT_ENDPOINT_URL=web_app_redirect_endpoint_url + - SAML_CERT=your_x509_certificate_base64_encoded + - SAML_PRIVATE_KEY=your_private_key_base64_encoded (if applicable) + - SAML_IDENTIFIER_FORMAT=urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + - SAML_ACCEPTED_CLOCK_SKEW_MS=5000 + - SESSION_SECRET=SESSION_SECRET + +3. Enable SAML authentication in Switcher Management by setting the environment variable SAML_ENABLE=true + +* `service_provider` refers to Switcher API +* `web_app` refers to Switcher Management + ### Running Switcher API from Docker Composer manifest file This option leverages Switcher API and Switcher Management with minimum settings required. diff --git a/config/.env.dev b/config/.env.dev index fa524eb..fa50f97 100644 --- a/config/.env.dev +++ b/config/.env.dev @@ -17,6 +17,17 @@ METRICS_MAX_PAGE=50 GOOGLE_SKIP_AUTH=true SWITCHER_API_LOGGER=true +### SAML Configuration +SAML_ENTRY_POINT= +SAML_ISSUER=switcher-api +SAML_CALLBACK_ENDPOINT_URL=http://localhost:3000 +SAML_REDIRECT_ENDPOINT_URL=http://localhost:4200 +SAML_CERT= +SAML_PRIVATE_KEY= +SAML_IDENTIFIER_FORMAT=urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress +SAML_ACCEPTED_CLOCK_SKEW_MS=5000 +SESSION_SECRET=SESSION_SECRET + ### Switcher Management SSL_ENABLED=false SWITCHERAPI_URL=http://localhost:3000 diff --git a/docker-compose.yml b/docker-compose.yml index c6ea53f..ecad01c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,6 +59,16 @@ services: - BITBUCKET_OAUTH_SECRET=${BITBUCKET_OAUTH_SECRET} - GOOGLE_RECAPTCHA_SECRET=${GOOGLE_RECAPTCHA_SECRET} - GOOGLE_SKIP_AUTH=${GOOGLE_SKIP_AUTH} + + - SAML_ENTRY_POINT=${SAML_ENTRY_POINT} + - SAML_ISSUER=${SAML_ISSUER} + - SAML_CALLBACK_ENDPOINT_URL=${SAML_CALLBACK_ENDPOINT_URL} + - SAML_REDIRECT_ENDPOINT_URL=${SAML_REDIRECT_ENDPOINT_URL} + - SAML_CERT=${SAML_CERT} + - SAML_PRIVATE_KEY=${SAML_PRIVATE_KEY} + - SAML_IDENTIFIER_FORMAT=${SAML_IDENTIFIER_FORMAT} + - SAML_ACCEPTED_CLOCK_SKEW_MS=${SAML_ACCEPTED_CLOCK_SKEW_MS} + - SESSION_SECRET=${SESSION_SECRET} - SWITCHER_API_LOGGER=${SWITCHER_API_LOGGER} - SWITCHER_API_LOGGER_LEVEL=${SWITCHER_API_LOGGER_LEVEL} diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 81fe216..941bcb3 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -9,12 +9,14 @@ "version": "1.4.1", "license": "MIT", "dependencies": { - "axios": "^1.12.1", + "@node-saml/passport-saml": "^5.1.0", + "axios": "^1.12.2", "bcryptjs": "^3.0.2", "cors": "^2.8.5", "express": "^5.1.0", "express-basic-auth": "^1.2.1", "express-rate-limit": "^8.1.0", + "express-session": "^1.18.2", "express-validator": "^7.2.1", "graphql": "^16.11.0", "graphql-http": "^1.22.4", @@ -24,6 +26,7 @@ "moment": "^2.30.1", "mongodb": "^6.19.0", "mongoose": "^8.18.1", + "passport": "^0.7.0", "pino": "^9.9.5", "pino-pretty": "^13.1.1", "swagger-ui-express": "^5.0.1", @@ -1336,6 +1339,46 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@node-saml/node-saml": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.1.0.tgz", + "integrity": "sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.12", + "@types/qs": "^6.9.18", + "@types/xml-encryption": "^1.2.4", + "@types/xml2js": "^0.4.14", + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.10", + "debug": "^4.4.0", + "xml-crypto": "^6.1.2", + "xml-encryption": "^3.1.0", + "xml2js": "^0.6.2", + "xmlbuilder": "^15.1.1", + "xpath": "^0.0.34" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@node-saml/passport-saml": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@node-saml/passport-saml/-/passport-saml-5.1.0.tgz", + "integrity": "sha512-pBm+iFjv9eihcgeJuSUs4c0AuX1QEFdHwP8w1iaWCfDzXdeWZxUBU5HT2bY2S4dvNutcy+A9hYsH7ZLBGtgwDg==", + "license": "MIT", + "dependencies": { + "@node-saml/node-saml": "^5.1.0", + "@types/express": "^4.17.23", + "@types/passport": "^1.0.17", + "@types/passport-strategy": "^0.2.38", + "passport": "^0.7.0", + "passport-strategy": "^1.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", @@ -1481,6 +1524,34 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1488,6 +1559,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1522,14 +1623,77 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { - "version": "24.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.3.tgz", - "integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==", - "dev": true, + "version": "24.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.4.0.tgz", + "integrity": "sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==", "license": "MIT", "dependencies": { - "undici-types": "~7.10.0" + "undici-types": "~7.11.0" + } + }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" } }, "node_modules/@types/stack-utils": { @@ -1554,6 +1718,24 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/xml-encryption": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/xml-encryption/-/xml-encryption-1.2.4.tgz", + "integrity": "sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/xml2js": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", + "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -1847,6 +2029,24 @@ "win32" ] }, + "node_modules/@xmldom/is-dom-node": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz", + "integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1989,9 +2189,9 @@ } }, "node_modules/axios": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz", - "integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -3272,6 +3472,46 @@ "express": ">= 4.11" } }, + "node_modules/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/express-validator": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", @@ -5666,6 +5906,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5799,6 +6048,32 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5863,6 +6138,11 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6172,6 +6452,15 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6335,6 +6624,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, "node_modules/secure-json-parse": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.0.0.tgz", @@ -7090,6 +7385,18 @@ "node": ">= 0.6" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -7098,10 +7405,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", - "dev": true, + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.11.0.tgz", + "integrity": "sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==", "license": "MIT" }, "node_modules/unpipe": { @@ -7189,6 +7495,15 @@ "punycode": "^2.1.0" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -7412,6 +7727,89 @@ "dev": true, "license": "MIT" }, + "node_modules/xml-crypto": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz", + "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", + "license": "MIT", + "dependencies": { + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.10", + "xpath": "^0.0.33" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/xml-crypto/node_modules/xpath": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz", + "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/xml-encryption": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.1.0.tgz", + "integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.5", + "escape-html": "^1.0.3", + "xpath": "0.0.32" + } + }, + "node_modules/xml-encryption/node_modules/xpath": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", + "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xpath": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", + "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index e22ea6f..059a6fc 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "coveragePathIgnorePatterns": [ "/node_modules/", "/src/api-docs/", + "/src/external/saml.js", "/src/app-server.js" ] }, @@ -36,12 +37,13 @@ ], "license": "MIT", "dependencies": { - "axios": "^1.12.1", + "axios": "^1.12.2", "bcryptjs": "^3.0.2", "cors": "^2.8.5", "express": "^5.1.0", "express-basic-auth": "^1.2.1", "express-rate-limit": "^8.1.0", + "express-session": "^1.18.2", "express-validator": "^7.2.1", "graphql": "^16.11.0", "graphql-http": "^1.22.4", @@ -51,6 +53,8 @@ "moment": "^2.30.1", "mongodb": "^6.19.0", "mongoose": "^8.18.1", + "passport": "^0.7.0", + "@node-saml/passport-saml": "^5.1.0", "pino": "^9.9.5", "pino-pretty": "^13.1.1", "swagger-ui-express": "^5.0.1", diff --git a/src/api-docs/paths/path-admin-saml.js b/src/api-docs/paths/path-admin-saml.js new file mode 100644 index 0000000..989d953 --- /dev/null +++ b/src/api-docs/paths/path-admin-saml.js @@ -0,0 +1,78 @@ +import { commonSchemaContent } from './common.js'; + +export default { + '/admin/saml/login': { + get: { + tags: ['Admin SSO'], + description: 'Initiate SAML SSO login', + responses: { + '302': { + description: 'Redirect to SAML Identity Provider' + } + } + } + }, + '/admin/saml/callback': { + post: { + tags: ['Admin SSO'], + description: 'SAML callback endpoint', + requestBody: { + content: { + 'application/x-www-form-urlencoded': { + schema: { + type: 'object', + properties: { + SAMLResponse: { + type: 'string', + description: 'SAML response from Identity Provider' + } + } + } + } + } + }, + responses: { + '302': { + description: 'Redirect to web app with token in URL fragment' + }, + '401': { + description: 'SAML authentication failed' + } + } + } + }, + '/admin/saml/auth': { + post: { + tags: ['Admin SSO'], + description: 'Authenticate or register SAML user', + security: [{ + bearerAuth: [] + }], + responses: { + '200': { + description: 'Success', + content: commonSchemaContent('AdminLoginResponse') + } + } + } + }, + '/admin/saml/metadata': { + get: { + tags: ['Admin SSO'], + description: 'Retrieve SAML metadata', + responses: { + '200': { + description: 'Success', + content: { + 'application/xml': { + schema: { + type: 'string', + description: 'SAML metadata XML' + } + } + } + } + } + } + } +}; \ No newline at end of file diff --git a/src/api-docs/swagger-document.js b/src/api-docs/swagger-document.js index 5b44577..938726b 100644 --- a/src/api-docs/swagger-document.js +++ b/src/api-docs/swagger-document.js @@ -1,4 +1,5 @@ import pathAdmin from './paths/path-admin.js'; +import pathAdminSaml from './paths/path-admin-saml.js'; import pathDomain from './paths/path-domain.js'; import pathGroup from './paths/path-group-config.js'; import pathConfig from './paths/path-config.js'; @@ -75,6 +76,7 @@ export default { }, paths: { ...pathAdmin, + ...pathAdminSaml, ...pathDomain, ...pathGroup, ...pathConfig, diff --git a/src/app.js b/src/app.js index d43c469..4eb35ef 100644 --- a/src/app.js +++ b/src/app.js @@ -1,4 +1,6 @@ import express from 'express'; +import session from 'express-session'; +import passport from 'passport'; import swaggerUi from 'swagger-ui-express'; import { createHandler } from 'graphql-http/lib/use/express'; import cors from 'cors'; @@ -9,6 +11,7 @@ import './db/mongoose.js'; import mongoose from 'mongoose'; import swaggerDocument from './api-docs/swagger-document.js'; import adminRouter from './routers/admin.js'; +import adminSamlRouter from './routers/admin-saml.js'; import environment from './routers/environment.js'; import component from './routers/component.js'; import domainRouter from './routers/domain.js'; @@ -27,6 +30,7 @@ import { createServer } from './app-server.js'; const app = express(); app.use(express.json()); +app.use(express.urlencoded({ extended: true })); /** * Cors configuration @@ -35,10 +39,25 @@ app.use(cors()); app.use(helmet()); app.disable('x-powered-by'); +/** + * Session configuration for SAML + */ +app.use(session({ + secret: process.env.SESSION_SECRET || 'switcher-api-session', + resave: false, + saveUninitialized: false, + cookie: { + secure: process.env.NODE_ENV === 'prod', + maxAge: 24 * 60 * 60 * 1000 // 24 hours + } +})); +app.use(passport.initialize()); + /** * API Routes */ app.use(adminRouter); +app.use(adminSamlRouter); app.use(component); app.use(environment); app.use(domainRouter); diff --git a/src/external/saml.js b/src/external/saml.js new file mode 100644 index 0000000..9063eb9 --- /dev/null +++ b/src/external/saml.js @@ -0,0 +1,51 @@ +import { Strategy as SamlStrategy } from '@node-saml/passport-saml'; +import passport from 'passport'; +import { signUpSaml } from '../services/admin.js'; +import Logger from '../helpers/logger.js'; + +function isSamlAvailable() { + return process.env.SAML_ENTRY_POINT && process.env.SAML_CALLBACK_ENDPOINT_URL && process.env.SAML_CERT; +} + +if (isSamlAvailable()) { + const samlOptions = { + entryPoint: process.env.SAML_ENTRY_POINT, + issuer: process.env.SAML_ISSUER || 'switcher-api', + callbackUrl: `${process.env.SAML_CALLBACK_ENDPOINT_URL}/admin/saml/callback`, + idpCert: Buffer.from(process.env.SAML_CERT, 'base64').toString('utf8'), + privateKey: process.env.SAML_PRIVATE_KEY ? Buffer.from(process.env.SAML_PRIVATE_KEY, 'base64').toString('utf8') : undefined, + identifierFormat: process.env.SAML_IDENTIFIER_FORMAT || 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + acceptedClockSkewMs: process.env.SAML_ACCEPTED_CLOCK_SKEW_MS ? parseInt(process.env.SAML_ACCEPTED_CLOCK_SKEW_MS, 10) : 5000, + signatureAlgorithm: 'sha256', + digestAlgorithm: 'sha256', + wantAssertionsSigned: true, + wantAuthnResponseSigned: false, + }; + + const samlStrategy = new SamlStrategy(samlOptions, async (profile, done) => { + try { + const userInfo = { + id: profile.nameID, + email: profile.email || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'], + name: profile.firstName || profile.nameID + }; + + const { jwt } = await signUpSaml(userInfo); + return done(null, { token: jwt.token }); + } catch (error) { + Logger.error('SAML Strategy Error Event:', error); + return done(error); + } + }); + + passport.use('saml', samlStrategy); + Logger.info('SSO enabled: SAML strategy configured'); + Logger.info(` - Entry Point: ${samlOptions.entryPoint}`); + Logger.info(` - Callback URL: ${samlOptions.callbackUrl}`); + Logger.info(` - Issuer: ${samlOptions.issuer}`); + Logger.info(` - Identifier Format: ${samlOptions.identifierFormat}`); + Logger.info(` - Accepted Clock Skew (ms): ${samlOptions.acceptedClockSkewMs}`); + Logger.info(` - Idp Cert: ${samlOptions.idpCert ? 'Provided' : 'Not Provided'}`); + Logger.info(` - Private Key: ${samlOptions.privateKey ? 'Provided' : 'Not Provided'}`); +} + diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 2006318..5700138 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -41,7 +41,7 @@ export async function authRefreshToken(req, res, next) { throw new Error('Refresh code does not match'); } - const decoded = await jwt.decode(token); + const decoded = jwt.decode(token); const admin = await getAdmin({ _id: decoded._id, token: decodedRefreshToken.subject }); if (!admin?.active) { diff --git a/src/models/admin.js b/src/models/admin.js index 61a707f..0bbdd18 100644 --- a/src/models/admin.js +++ b/src/models/admin.js @@ -42,14 +42,23 @@ const adminSchema = new mongoose.Schema({ }, auth_provider: { type: String, - enum: ['email', 'github', 'bitbucket'], + enum: ['email', 'github', 'bitbucket', 'saml'], default: 'email' }, _gitid: { - type: String + type: String, + unique: true, + sparse: true }, _bitbucketid: { - type: String + type: String, + unique: true, + sparse: true + }, + _samlid: { + type: String, + unique: true, + sparse: true }, _avatar: { type: String @@ -141,7 +150,7 @@ adminSchema.methods.generateAuthCode = async function () { }; adminSchema.statics.findByCredentials = async (email, password) => { - const admin = await Admin.findOne({ email, active: true }).exec(); + const admin = await Admin.findOneBy({ email, active: true }); if (!admin) { throw new Error('Unable to login - account not found or not active.'); @@ -156,16 +165,30 @@ adminSchema.statics.findByCredentials = async (email, password) => { return admin; }; +adminSchema.statics.findOneBy = async (criteria) => { + for (const key in criteria) { + if (criteria[key] === undefined) { + return null; + } + } + + return Admin.findOne(criteria).exec(); +}; + adminSchema.statics.findUserByAuthCode = async (code, active) => { - return Admin.findOne({ code, active }); + return Admin.findOneBy({ code, active }); }; adminSchema.statics.findUserByGitId = async (_gitid) => { - return Admin.findOne({ _gitid }); + return Admin.findOneBy({ _gitid }); }; adminSchema.statics.findUserByBitBucketId = async (_bitbucketid) => { - return Admin.findOne({ _bitbucketid }); + return Admin.findOneBy({ _bitbucketid }); +}; + +adminSchema.statics.findUserBySamlId = async (_samlid) => { + return Admin.findOneBy({ _samlid }); }; adminSchema.statics.createThirdPartyAccount = async ( @@ -190,7 +213,6 @@ adminSchema.statics.createThirdPartyAccount = async ( await admin.save(); } - admin.name = userInfo.name; admin._avatar = userInfo.avatar; return admin; }; diff --git a/src/routers/admin-saml.js b/src/routers/admin-saml.js new file mode 100644 index 0000000..05cca4d --- /dev/null +++ b/src/routers/admin-saml.js @@ -0,0 +1,52 @@ +import '../external/saml.js'; +import { generateServiceProviderMetadata } from '@node-saml/passport-saml'; +import express from 'express'; +import passport from 'passport'; +import * as Services from '../services/admin.js'; +import { auth } from '../middleware/auth.js'; +import { responseException } from '../exceptions/index.js'; + +const router = new express.Router(); + +router.get('/admin/saml/login', passport.authenticate('saml')); + +router.post('/admin/saml/callback', (req, res, next) => { + passport.authenticate('saml', { session: false }, (err, user) => { + if (err || !user) { + return res.status(401).json({ error: 'Authentication failed' }); + } + + const { token } = user; + const redirectTo = `${process.env.SAML_REDIRECT_ENDPOINT_URL}/login`; + const fragment = `token=${encodeURIComponent(token)}`; + + res.redirect(`${redirectTo}#${fragment}`); + })(req, res, next); +}); + +router.post('/admin/saml/auth', auth, async (req, res) => { + try { + const { admin, jwt } = await Services.signUpSaml({ + id: req.admin._samlid, + email: req.admin.email, + name: req.admin.name + }); + + res.status(200).send({ admin, jwt }); + } catch (e) { + responseException(res, e, 401); + } +}); + +router.get('/admin/saml/metadata', (_, res) => { + const metadata = generateServiceProviderMetadata({ + issuer: process.env.SAML_ISSUER, + callbackUrl: process.env.SAML_CALLBACK_URL, + publicCerts: process.env.SAML_CERT + }); + + res.set('Content-Type', 'application/xml'); + res.send(metadata); +}); + +export default router; \ No newline at end of file diff --git a/src/services/admin.js b/src/services/admin.js index b2e28da..5f347c8 100644 --- a/src/services/admin.js +++ b/src/services/admin.js @@ -55,6 +55,14 @@ export async function signUpBitbucket(code) { return { admin, jwt }; } +export async function signUpSaml(userInfo) { + let admin = await Admin.findUserBySamlId(userInfo.id); + admin = await Admin.createThirdPartyAccount( + admin, userInfo, 'saml', '_samlid', checkAdmin); + const jwt = await admin.generateAuthToken(); + return { admin, jwt }; +} + export async function signIn(email, password) { const admin = await Admin.findByCredentials(email, password); const jwt = await admin.generateAuthToken(); diff --git a/tests/admin-saml.test.js b/tests/admin-saml.test.js new file mode 100644 index 0000000..76ad5a7 --- /dev/null +++ b/tests/admin-saml.test.js @@ -0,0 +1,160 @@ +import request from 'supertest'; +import mongoose from 'mongoose'; +import passport from 'passport'; +import app from '../src/app'; +import { + adminAccountToken, + adminSamlAccountToken, + setupDatabase +} from './fixtures/db_api'; + +class TestSamlStrategy { + constructor(name, _, verify) { + this.name = name || 'test-saml'; + this._verify = verify; + this._testResult = null; + } + + setTestResult(result) { + this._testResult = result; + } + + authenticate(_) { + if (this._testResult?.error) { + return this.error(this._testResult.error); + } + + if (this._testResult?.fail) { + return this.fail(this._testResult.fail); + } + + if (this._testResult?.user) { + return this._verify(this._testResult.user, (err, user) => { + if (err) return this.error(err); + if (!user) return this.fail(); + this.success(user); + }); + } + + this.fail(); + } +} + +afterAll(async () => { + await new Promise(resolve => setTimeout(resolve, 1000)); + await mongoose.disconnect(); +}); + +describe('SAML Authentication', () => { + let testStrategy; + let originalStrategy; + + beforeEach(setupDatabase); + + afterEach(() => { + if (originalStrategy) { + passport.use('saml', originalStrategy); + originalStrategy = null; + } + }); + + const setupTestStrategy = async () => { + originalStrategy = passport.Strategy('saml'); + testStrategy = new TestSamlStrategy('saml', {}, async (profile, done) => { + return done(null, profile); + }); + + passport.use('saml', testStrategy); + }; + + test('SAML_SUITE - Should redirect to SAML IdP on login request', async () => { + const response = await request(app) + .get('/admin/saml/login') + .expect(302); + + expect(response.headers.location).toBeDefined(); + }); + + test('SAML_SUITE - Should return SAML metadata', async () => { + const response = await request(app) + .get('/admin/saml/metadata') + .expect(200); + + expect(response.headers['content-type']).toContain('application/xml'); + expect(response.text).toContain('EntityDescriptor'); + }); + + test('SAML_SUITE - Should handle successful SAML callback with valid user token', async () => { + // given - a valid SAML response + await setupTestStrategy(); + testStrategy.setTestResult({ user: { token: 'mockToken' } }); + + // test + const response = await request(app) + .post('/admin/saml/callback') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send('SAMLResponse=test') + .expect(302); + + expect(response.headers.location).toBeDefined(); + expect(response.headers.location).toMatch(/token=mockToken/); + }); + + test('SAML_SUITE - Should handle SAML callback authentication failure', async () => { + // given - an error during SAML authentication + await setupTestStrategy(); + + testStrategy.setTestResult({ + error: new Error('SAML authentication failed') + }); + + // test + const response = await request(app) + .post('/admin/saml/callback') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send('SAMLResponse=test'); + + expect(response.status).toBe(401); + }); + + test('SAML_SUITE - Should handle SAML callback with authentication failure', async () => { + // given - an error during SAML authentication + await setupTestStrategy(); + + testStrategy.setTestResult({ + fail: 'Invalid SAML response' + }); + + // test + await request(app) + .post('/admin/saml/callback') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send('SAMLResponse=test') + .expect(401); + }); + + test('SAML_SUITE - Should authenticate SAML user from redirect', async () => { + // test + const response = await request(app) + .post('/admin/saml/auth') + .set('Authorization', `Bearer ${adminSamlAccountToken}`) + .expect(200); + + expect(response.body.admin).toBeDefined(); + expect(response.body.jwt).toBeDefined(); + }); + + test('SAML_SUITE - Should reject SAML user authentication without token', async () => { + await request(app) + .post('/admin/saml/auth') + .expect(401); + }); + + test('SAML_SUITE - Should reject SAML user authentication - not valid SAML account', async () => { + await request(app) + .post('/admin/saml/auth') + .set('Authorization', `Bearer ${adminAccountToken}`) + .expect(401); + }); + +}); \ No newline at end of file diff --git a/tests/admin.test.js b/tests/admin.test.js index 8728632..41ca4d4 100644 --- a/tests/admin.test.js +++ b/tests/admin.test.js @@ -642,6 +642,25 @@ describe('Testing Admin login and fetch', () => { password: 'wrongpassword' }).expect(401); }); + + test('ADMIN_SUITE - Should not login non-active admin', async () => { + // given - deactivate admin + const admin = await Admin.findById(adminAccountId).exec(); + admin.active = false; + await admin.save(); + + // test + await request(app) + .post('/admin/login') + .send({ + email: adminAccount.email, + password: adminAccount.password + }).expect(401); + + // teardown - reactivate admin + admin.active = true; + await admin.save(); + }); test('ADMIN_SUITE - Should not login with wrong email format', async () => { await request(app) diff --git a/tests/fixtures/db_api.js b/tests/fixtures/db_api.js index 5b0bd5a..bbf7da6 100644 --- a/tests/fixtures/db_api.js +++ b/tests/fixtures/db_api.js @@ -40,6 +40,18 @@ export const adminAccount = { active: true }; +export const adminSamlAccountId = new mongoose.Types.ObjectId(); +export const adminSamlAccountToken = jwt.sign({ _id: adminSamlAccountId }, process.env.JWT_SECRET); +export const adminSamlAccount = { + _id: adminSamlAccountId, + name: 'Admin Saml', + email: 'admin.saml@mail.com', + password: 'asdasdasdasd', + _samlid: 'admin.saml@mail.com', + auth_provider: 'saml', + active: true +}; + export const memberAccountId = new mongoose.Types.ObjectId(); export const memberAccountToken = jwt.sign({ _id: memberAccountId }, process.env.JWT_SECRET); export const memberAccount = { @@ -264,6 +276,9 @@ export const setupDatabase = async () => { adminAccount.token = Admin.extractTokenPart(adminAccountToken); await new Admin(adminAccount).save(); + adminSamlAccount.token = Admin.extractTokenPart(adminSamlAccountToken); + await new Admin(adminSamlAccount).save(); + memberAccount.token = Admin.extractTokenPart(memberAccountToken); await new Admin(memberAccount).save();