From 4ef3df4a231376bd016e697b87feaba3c1ad4525 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sat, 20 Dec 2025 17:24:10 +0300 Subject: [PATCH 01/33] deps added, env updated --- .env.sample | 4 ++ package.json | 1 + src/types/env.d.ts | 8 ++++ yarn.lock | 112 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+) diff --git a/.env.sample b/.env.sample index efbdd12e..beea3bb7 100644 --- a/.env.sample +++ b/.env.sample @@ -90,3 +90,7 @@ AWS_S3_SECRET_ACCESS_KEY= AWS_S3_BUCKET_NAME= AWS_S3_BUCKET_BASE_URL= AWS_S3_BUCKET_ENDPOINT= + +# SSO Service Provider Entity ID +# Unique identifier for Hawk in SAML IdP configuration +SSO_SP_ENTITY_ID=urn:hawk:tracker:saml diff --git a/package.json b/package.json index d710f56c..ee78c204 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@types/uuid": "^8.3.4", "ai": "^5.0.89", "amqp-connection-manager": "^3.1.0", + "@node-saml/node-saml": "^5.0.1", "amqplib": "^0.5.5", "apollo-server-express": "^3.10.0", "argon2": "^0.28.7", diff --git a/src/types/env.d.ts b/src/types/env.d.ts index 82eb4ce9..f75bbeed 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -30,5 +30,13 @@ declare namespace NodeJS { * Secret string for encoding/decoding user's tokens */ JWT_SECRET_AUTH: string; + + /** + * SSO Service Provider Entity ID + * Unique identifier for Hawk in SAML IdP configuration + * + * @example "urn:hawk:tracker:saml" + */ + SSO_SP_ENTITY_ID: string; } } diff --git a/yarn.lock b/yarn.lock index 0782faeb..f067fe46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -758,6 +758,24 @@ resolved "https://registry.yarnpkg.com/@n1ru4l/json-patch-plus/-/json-patch-plus-0.2.0.tgz#b8fa09fd980c3460dfdc109a7c4cc5590157aa6b" integrity sha512-pLkJy83/rVfDTyQgDSC8GeXAHEdXNHGNJrB1b7wAyGQu0iv7tpMXntKVSqj0+XKNVQbco40SZffNfVALzIt0SQ== +"@node-saml/node-saml@^5.0.1": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@node-saml/node-saml/-/node-saml-5.1.0.tgz#43d61d4ea882f2960a44c7be5ae0030dafea2382" + integrity sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw== + 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" + "@opentelemetry/api@1.9.0", "@opentelemetry/api@^1.4.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" @@ -1010,6 +1028,13 @@ resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw== +"@types/debug@^4.1.12": + version "4.1.12" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" + integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== + dependencies: + "@types/ms" "*" + "@types/debug@^4.1.5": version "4.1.7" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82" @@ -1266,6 +1291,11 @@ resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== +"@types/qs@^6.9.18": + version "6.14.0" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.14.0.tgz#d8b60cecf62f2db0fb68e5e006077b9178b85de5" + integrity sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ== + "@types/qs@^6.9.7": version "6.9.17" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.17.tgz#fc560f60946d0aeff2f914eb41679659d3310e1a" @@ -1314,6 +1344,20 @@ resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== +"@types/xml-encryption@^1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@types/xml-encryption/-/xml-encryption-1.2.4.tgz#0eceea58c82a89f62c0a2dc383a6461dfc2fe1ba" + integrity sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q== + dependencies: + "@types/node" "*" + +"@types/xml2js@^0.4.14": + version "0.4.14" + resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.14.tgz#5d462a2a7330345e2309c6b549a183a376de8f9a" + integrity sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" @@ -1374,6 +1418,16 @@ resolved "https://registry.yarnpkg.com/@vercel/oidc/-/oidc-3.0.3.tgz#82c2b6dd4d5c3b37dcb1189718cdeb9db402d052" integrity sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg== +"@xmldom/is-dom-node@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz#83b9f3e1260fb008061c6fa787b93a00f9be0629" + integrity sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q== + +"@xmldom/xmldom@^0.8.10", "@xmldom/xmldom@^0.8.5": + version "0.8.11" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz#b79de2d67389734c57c52595f7a7305e30c2d608" + integrity sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw== + abab@^2.0.3, abab@^2.0.5: version "2.0.6" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" @@ -2481,6 +2535,13 @@ debug@^4.2.0: dependencies: ms "^2.1.3" +debug@^4.4.0: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -7182,6 +7243,24 @@ ws@^7.4.6: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== +xml-crypto@^6.1.2: + version "6.1.2" + resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-6.1.2.tgz#ed93e87d9538f92ad1ad2db442e9ec586723d07d" + integrity sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w== + dependencies: + "@xmldom/is-dom-node" "^1.0.1" + "@xmldom/xmldom" "^0.8.10" + xpath "^0.0.33" + +xml-encryption@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/xml-encryption/-/xml-encryption-3.1.0.tgz#f3e91c4508aafd0c21892151ded91013dcd51ca2" + integrity sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q== + dependencies: + "@xmldom/xmldom" "^0.8.5" + escape-html "^1.0.3" + xpath "0.0.32" + xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" @@ -7195,6 +7274,24 @@ xml2js@0.4.19: sax ">=0.6.0" xmlbuilder "~9.0.1" +xml2js@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.2.tgz#dd0b630083aa09c161e25a4d0901e2b2a929b499" + integrity sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + +xmlbuilder@^15.1.1: + version "15.1.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5" + integrity sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg== + +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + xmlbuilder@~9.0.1: version "9.0.7" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" @@ -7205,6 +7302,21 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xpath@0.0.32: + version "0.0.32" + resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.32.tgz#1b73d3351af736e17ec078d6da4b8175405c48af" + integrity sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw== + +xpath@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.33.tgz#5136b6094227c5df92002e7c3a13516a5074eb07" + integrity sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA== + +xpath@^0.0.34: + version "0.0.34" + resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.34.tgz#a769255e8816e0938e1e0005f2baa7279be8be12" + integrity sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA== + xss@^1.0.8: version "1.0.13" resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.13.tgz#6e48f616128b39f366dfadc57411e1eb5b341c6c" From 3ecdd4802a28db218b8119329b82339f696ab642 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sat, 20 Dec 2025 17:35:10 +0300 Subject: [PATCH 02/33] bootstrap module --- src/sso/index.ts | 15 +++++ src/sso/saml/controller.ts | 56 ++++++++++++++++++ src/sso/saml/index.ts | 41 +++++++++++++ src/sso/saml/service.ts | 115 ++++++++++++++++++++++++++++++++++++ src/sso/saml/store.ts | 117 +++++++++++++++++++++++++++++++++++++ src/sso/saml/types.ts | 75 ++++++++++++++++++++++++ src/sso/saml/utils.ts | 35 +++++++++++ src/sso/types.ts | 113 +++++++++++++++++++++++++++++++++++ 8 files changed, 567 insertions(+) create mode 100644 src/sso/index.ts create mode 100644 src/sso/saml/controller.ts create mode 100644 src/sso/saml/index.ts create mode 100644 src/sso/saml/service.ts create mode 100644 src/sso/saml/store.ts create mode 100644 src/sso/saml/types.ts create mode 100644 src/sso/saml/utils.ts create mode 100644 src/sso/types.ts diff --git a/src/sso/index.ts b/src/sso/index.ts new file mode 100644 index 00000000..bfc76739 --- /dev/null +++ b/src/sso/index.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import { createSamlRouter } from './saml'; +import { ContextFactories } from '../types/graphql'; + +/** + * Append SSO routes to Express app + * + * @param app - Express application instance + * @param factories - context factories for database access + */ +export function appendSsoRoutes(app: express.Application, factories: ContextFactories): void { + const samlRouter = createSamlRouter(factories); + app.use('/auth/sso/saml', samlRouter); +} + diff --git a/src/sso/saml/controller.ts b/src/sso/saml/controller.ts new file mode 100644 index 00000000..55256cd7 --- /dev/null +++ b/src/sso/saml/controller.ts @@ -0,0 +1,56 @@ +import express from 'express'; +import SamlService from './service'; +import samlStore from './store'; +import { ContextFactories } from '../../types/graphql'; + +/** + * Controller for SAML SSO endpoints + */ +export default class SamlController { + /** + * SAML service instance + */ + private samlService: SamlService; + + /** + * Context factories for database access + */ + private factories: ContextFactories; + + constructor(factories: ContextFactories) { + this.samlService = new SamlService(); + this.factories = factories; + } + + /** + * Compose Assertion Consumer Service URL for workspace + * + * @param workspaceId - workspace ID + * @returns ACS URL + */ + private getAcsUrl(workspaceId: string): string { + const apiUrl = process.env.API_URL || 'https://api.hawk.so'; + return `${apiUrl}/auth/sso/saml/${workspaceId}/acs`; + } + + /** + * Initiate SSO login (GET /auth/sso/saml/:workspaceId) + */ + public async initiateLogin(req: express.Request, res: express.Response): Promise { + /** + * TODO: Implement according to specification + */ + throw new Error('Not implemented'); + } + + /** + * Handle ACS callback (POST /auth/sso/saml/:workspaceId/acs) + */ + public async handleAcs(req: express.Request, res: express.Response): Promise { + /** + * TODO: Implement according to specification + */ + throw new Error('Not implemented'); + } +} + diff --git a/src/sso/saml/index.ts b/src/sso/saml/index.ts new file mode 100644 index 00000000..23596358 --- /dev/null +++ b/src/sso/saml/index.ts @@ -0,0 +1,41 @@ +import express from 'express'; +import SamlController from './controller'; +import { ContextFactories } from '../../types/graphql'; + +/** + * Create SAML router + * + * @param factories - context factories for database access + * @returns Express router with SAML endpoints + */ +export function createSamlRouter(factories: ContextFactories): express.Router { + const router = express.Router(); + const controller = new SamlController(factories); + + /** + * SSO login initiation + * GET /auth/sso/saml/:workspaceId + */ + router.get('/:workspaceId', async (req, res, next) => { + try { + await controller.initiateLogin(req, res); + } catch (error) { + next(error); + } + }); + + /** + * ACS callback + * POST /auth/sso/saml/:workspaceId/acs + */ + router.post('/:workspaceId/acs', async (req, res, next) => { + try { + await controller.handleAcs(req, res); + } catch (error) { + next(error); + } + }); + + return router; +} + diff --git a/src/sso/saml/service.ts b/src/sso/saml/service.ts new file mode 100644 index 00000000..f4bde797 --- /dev/null +++ b/src/sso/saml/service.ts @@ -0,0 +1,115 @@ +import { SamlConfig, SamlResponseData } from '../types'; +import { SamlValidationError, SamlValidationErrorType } from './types'; + +/** + * Service for SAML SSO operations + */ +export default class SamlService { + /** + * Generate SAML AuthnRequest + * + * @param workspaceId - workspace ID + * @param acsUrl - Assertion Consumer Service URL + * @param relayState - relay state to pass through + * @param samlConfig - SAML configuration + * @returns AuthnRequest ID and encoded SAML request + */ + public async generateAuthnRequest( + workspaceId: string, + acsUrl: string, + relayState: string, + samlConfig: SamlConfig + ): Promise<{ requestId: string; encodedRequest: string }> { + /** + * @todo Implement using @node-saml/node-saml + * + * This method should: + * 1. Generate unique AuthnRequest ID + * 2. Create SAML AuthnRequest XML + * 3. Encode it as base64 + * 4. Return both requestId and encoded request + */ + throw new Error('Not implemented'); + } + + /** + * Validate and parse SAML Response + * + * @param samlResponse - base64-encoded SAML Response + * @param workspaceId - workspace ID + * @param acsUrl - expected Assertion Consumer Service URL + * @param samlConfig - SAML configuration + * @returns parsed SAML response data + */ + public async validateAndParseResponse( + samlResponse: string, + workspaceId: string, + acsUrl: string, + samlConfig: SamlConfig + ): Promise { + /** + * @todo Implement using @node-saml/node-saml + * + * This method should: + * 1. Decode base64 SAML Response + * 2. Validate XML signature using x509Cert + * 3. Validate Audience (should match SSO_SP_ENTITY_ID) + * 4. Validate Recipient (should match acsUrl) + * 5. Validate InResponseTo (should match saved AuthnRequest ID) + * 6. Validate time conditions (NotBefore, NotOnOrAfter) + * 7. Extract NameID + * 8. Extract email using attributeMapping + * 9. Extract name using attributeMapping (if available) + * 10. Return parsed data + */ + throw new Error('Not implemented'); + } + + /** + * Validate Audience value + * + * @param audience - audience value from SAML Assertion + * @returns true if audience matches SSO_SP_ENTITY_ID + */ + public validateAudience(audience: string): boolean { + const spEntityId = process.env.SSO_SP_ENTITY_ID; + + if (!spEntityId) { + throw new Error('SSO_SP_ENTITY_ID environment variable is not set'); + } + + return audience === spEntityId; + } + + /** + * Validate Recipient value + * + * @param recipient - recipient URL from SAML Assertion + * @param expectedAcsUrl - expected ACS URL + * @returns true if recipient matches expected ACS URL + */ + public validateRecipient(recipient: string, expectedAcsUrl: string): boolean { + return recipient === expectedAcsUrl; + } + + /** + * Validate time conditions (NotBefore and NotOnOrAfter) + * + * @param notBefore - NotBefore timestamp + * @param notOnOrAfter - NotOnOrAfter timestamp + * @param clockSkew - allowed clock skew in milliseconds (default: 2 minutes) + * @returns true if assertion is valid at current time + */ + public validateTimeConditions( + notBefore: Date, + notOnOrAfter: Date, + clockSkew: number = 2 * 60 * 1000 + ): boolean { + const now = Date.now(); + const notBeforeTime = notBefore.getTime() - clockSkew; + const notOnOrAfterTime = notOnOrAfter.getTime() + clockSkew; + + return now >= notBeforeTime && now < notOnOrAfterTime; + } +} + diff --git a/src/sso/saml/store.ts b/src/sso/saml/store.ts new file mode 100644 index 00000000..26d2c6f2 --- /dev/null +++ b/src/sso/saml/store.ts @@ -0,0 +1,117 @@ +import { RelayStateData, AuthnRequestState } from './types'; + +/** + * In-memory store for SAML state + * @todo Replace with Redis for production + */ +class SamlStateStore { + /** + * Map of relay state IDs to relay state data + */ + private relayStates: Map = new Map(); + + /** + * Map of AuthnRequest IDs to AuthnRequest state + */ + private authnRequests: Map = new Map(); + + /** + * Time-to-live for stored state (5 minutes) + */ + private readonly TTL = 5 * 60 * 1000; + + /** + * Save relay state + */ + public saveRelayState(stateId: string, data: { returnUrl: string; workspaceId: string }): void { + this.relayStates.set(stateId, { + ...data, + expiresAt: Date.now() + this.TTL, + }); + } + + /** + * Get relay state by ID + */ + public getRelayState(stateId: string): { returnUrl: string; workspaceId: string } | null { + const state = this.relayStates.get(stateId); + + if (!state) { + return null; + } + + if (Date.now() > state.expiresAt) { + this.relayStates.delete(stateId); + return null; + } + + return { returnUrl: state.returnUrl, workspaceId: state.workspaceId }; + } + + /** + * Save AuthnRequest state + */ + public saveAuthnRequest(requestId: string, workspaceId: string): void { + this.authnRequests.set(requestId, { + workspaceId, + expiresAt: Date.now() + this.TTL, + }); + } + + /** + * Validate and consume AuthnRequest + * Returns true if request is valid and not expired, false otherwise + * Removes the request from storage after validation + */ + public validateAndConsumeAuthnRequest(requestId: string, workspaceId: string): boolean { + const state = this.authnRequests.get(requestId); + + if (!state) { + return false; + } + + if (Date.now() > state.expiresAt) { + this.authnRequests.delete(requestId); + return false; + } + + if (state.workspaceId !== workspaceId) { + this.authnRequests.delete(requestId); + return false; + } + + /** + * Remove request after successful validation (prevent replay attacks) + */ + this.authnRequests.delete(requestId); + return true; + } + + /** + * Clean up expired entries (can be called periodically) + */ + public cleanup(): void { + const now = Date.now(); + + /** + * Clean up expired relay states + */ + for (const [id, state] of this.relayStates.entries()) { + if (now > state.expiresAt) { + this.relayStates.delete(id); + } + } + + /** + * Clean up expired AuthnRequests + */ + for (const [id, state] of this.authnRequests.entries()) { + if (now > state.expiresAt) { + this.authnRequests.delete(id); + } + } + } +} + +export default new SamlStateStore(); + diff --git a/src/sso/saml/types.ts b/src/sso/saml/types.ts new file mode 100644 index 00000000..d4666556 --- /dev/null +++ b/src/sso/saml/types.ts @@ -0,0 +1,75 @@ +/** + * Internal types for SAML module + * These types are used only within the SAML module implementation + */ + +/** + * Error types for SAML validation + */ +export enum SamlValidationErrorType { + INVALID_SIGNATURE = 'INVALID_SIGNATURE', + INVALID_AUDIENCE = 'INVALID_AUDIENCE', + INVALID_RECIPIENT = 'INVALID_RECIPIENT', + INVALID_IN_RESPONSE_TO = 'INVALID_IN_RESPONSE_TO', + EXPIRED_ASSERTION = 'EXPIRED_ASSERTION', + INVALID_NAME_ID = 'INVALID_NAME_ID', + MISSING_EMAIL = 'MISSING_EMAIL', +} + +/** + * SAML validation error + */ +export class SamlValidationError extends Error { + /** + * Error type + */ + public readonly type: SamlValidationErrorType; + + /** + * Additional error context + */ + public readonly context?: Record; + + constructor(type: SamlValidationErrorType, message: string, context?: Record) { + super(message); + this.name = 'SamlValidationError'; + this.type = type; + this.context = context; + } +} + +/** + * Stored AuthnRequest state + */ +export interface AuthnRequestState { + /** + * Workspace ID + */ + workspaceId: string; + + /** + * Expiration timestamp + */ + expiresAt: number; +} + +/** + * Stored RelayState data + */ +export interface RelayStateData { + /** + * Return URL after SSO login + */ + returnUrl: string; + + /** + * Workspace ID + */ + workspaceId: string; + + /** + * Expiration timestamp + */ + expiresAt: number; +} + diff --git a/src/sso/saml/utils.ts b/src/sso/saml/utils.ts new file mode 100644 index 00000000..32d90290 --- /dev/null +++ b/src/sso/saml/utils.ts @@ -0,0 +1,35 @@ +/** + * Utility functions for SAML operations + */ + +/** + * Extract attribute value from SAML Assertion attributes + * + * @param attributes - SAML attributes object + * @param attributeName - name of the attribute to extract + * @returns attribute value or undefined if not found + */ +export function extractAttribute(attributes: Record, attributeName: string): string | undefined { + const value = attributes[attributeName]; + + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value) && value.length > 0) { + return value[0]; + } + + return undefined; +} + +/** + * Validate PEM certificate format + * + * @param cert - certificate string + * @returns true if certificate appears to be valid PEM format + */ +export function isValidPemCertificate(cert: string): boolean { + return cert.includes('-----BEGIN CERTIFICATE-----') && cert.includes('-----END CERTIFICATE-----'); +} + diff --git a/src/sso/types.ts b/src/sso/types.ts new file mode 100644 index 00000000..97726314 --- /dev/null +++ b/src/sso/types.ts @@ -0,0 +1,113 @@ +/** + * SAML attribute mapping configuration + */ +export interface SamlAttributeMapping { + /** + * Attribute name for email in SAML Assertion + * + * @example "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" + * to get email from XML like this: + * + * alice@company.com + * + */ + email: string; + + /** + * Attribute name for user name in SAML Assertion + */ + name?: string; +} + +/** + * SAML SSO configuration + */ +export interface SamlConfig { + /** + * IdP Entity ID. + * Used to validate "this response is intended for Hawk" + * @example "urn:hawk:tracker:saml" + */ + idpEntityId: string; + + /** + * SSO URL for redirecting user to IdP + * Used to redirect user to IdP for authentication + * @example "https://idp.example.com/sso" + */ + ssoUrl: string; + + /** + * X.509 certificate for signature verification + * @example "-----BEGIN CERTIFICATE-----\nMIIDYjCCAkqgAwIBAgI...END CERTIFICATE-----" + */ + x509Cert: string; + + /** + * Desired NameID format + * @example "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + */ + nameIdFormat?: string; + + /** + * Attribute mapping configuration + * Used to extract user attributes from SAML Response + */ + attributeMapping: SamlAttributeMapping; +} + +/** + * SSO configuration for workspace + */ +export interface WorkspaceSsoConfig { + /** + * Is SSO enabled + */ + enabled: boolean; + + /** + * Is SSO enforced (only SSO login allowed) + * If true, login via email/password is not allowed + */ + enforced: boolean; + + /** + * SSO provider type + * Currently only SAML is supported. In future we can add other providers (OAuth 2, etc.) + */ + type: 'saml'; + + /** + * SAML-specific configuration. + * Got from IdP metadata. + */ + saml: SamlConfig; +} + +/** + * Data extracted from SAML Response + */ +export interface SamlResponseData { + /** + * NameID value (user identifier in IdP) + */ + nameId: string; + + /** + * User email + */ + email: string; + + /** + * User name (optional) + */ + name?: string; + + /** + * Identifier that should match AuthnRequest ID + * + * @example "_a8f7c3..." + */ + inResponseTo?: string; +} + From 73fa3b98956a115726ae19e8acdaa46a90d2631b Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sat, 20 Dec 2025 17:56:41 +0300 Subject: [PATCH 03/33] models updated --- package.json | 2 +- src/models/user.ts | 83 +++++++++++++++++++++++++++++++++++ src/models/usersFactory.ts | 15 +++++++ src/models/workspace.ts | 5 +++ src/sso/types.ts | 89 +++----------------------------------- yarn.lock | 15 ++++--- 6 files changed, 120 insertions(+), 89 deletions(-) diff --git a/package.json b/package.json index ee78c204..1b49a375 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@graphql-tools/schema": "^8.5.1", "@graphql-tools/utils": "^8.9.0", "@hawk.so/nodejs": "^3.1.1", - "@hawk.so/types": "^0.1.37", + "@hawk.so/types": "^0.4.0", "@n1ru4l/json-patch-plus": "^0.2.0", "@types/amqp-connection-manager": "^2.0.4", "@types/bson": "^4.0.5", diff --git a/src/models/user.ts b/src/models/user.ts index 45fc6c17..b062a8a4 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -142,6 +142,25 @@ export default class UserModel extends AbstractModel implements Us */ public utm?: UserDBScheme['utm']; + /** + * External identities for SSO (keyed by workspaceId) + */ + public identities?: { + [workspaceId: string]: { + saml: { + /** + * NameID value from IdP (stable identifier) + */ + id: string; + + /** + * Email at the time of linking (for audit) + */ + email: string; + }; + }; + }; + /** * Model's collection */ @@ -418,4 +437,68 @@ export default class UserModel extends AbstractModel implements Us }, }); } + + /** + * Link SAML identity to user for specific workspace + * + * @param workspaceId - workspace ID + * @param samlId - NameID value from IdP (stable identifier) + * @param email - user email at the time of linking + */ + public async linkSamlIdentity(workspaceId: string, samlId: string, email: string): Promise { + /** + * Use Record for MongoDB dot notation keys + */ + const updateData: Record = { + [`identities.${workspaceId}.saml.id`]: samlId, + [`identities.${workspaceId}.saml.email`]: email, + }; + + await this.update( + { _id: new ObjectId(this._id) }, + { $set: updateData } + ); + + /** + * Update local state + */ + if (!this.identities) { + this.identities = {}; + } + if (!this.identities[workspaceId]) { + this.identities[workspaceId] = { saml: { id: samlId, email } }; + } else { + this.identities[workspaceId].saml = { id: samlId, email }; + } + } + + /** + * Find user by SAML identity + * + * @param collection - users collection + * @param workspaceId - workspace ID + * @param samlId - NameID value from IdP + * @returns UserModel or null if not found + */ + public static async findBySamlIdentity( + collection: Collection, + workspaceId: string, + samlId: string + ): Promise { + const userData = await collection.findOne({ + [`identities.${workspaceId}.saml.id`]: samlId, + }); + + return userData ? new UserModel(userData) : null; + } + + /** + * Get SAML identity for workspace + * + * @param workspaceId - workspace ID + * @returns SAML identity or null if not found + */ + public getSamlIdentity(workspaceId: string): { id: string; email: string } | null { + return this.identities?.[workspaceId]?.saml || null; + } } diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index f3c6ff70..907b8d94 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -149,4 +149,19 @@ export default class UsersFactory extends AbstractModelFactory { + const userData = await this.collection.findOne({ + [`identities.${workspaceId}.saml.id`]: samlId, + }); + + return userData ? new UserModel(userData) : null; + } } diff --git a/src/models/workspace.ts b/src/models/workspace.ts index 2e6886a6..0ca91eaf 100644 --- a/src/models/workspace.ts +++ b/src/models/workspace.ts @@ -76,6 +76,11 @@ export default class WorkspaceModel extends AbstractModel imp */ public isDebug?: boolean; + /** + * SSO configuration + */ + public sso?: WorkspaceDBScheme['sso']; + /** * Model's collection */ diff --git a/src/sso/types.ts b/src/sso/types.ts index 97726314..234a8cc6 100644 --- a/src/sso/types.ts +++ b/src/sso/types.ts @@ -1,88 +1,11 @@ /** - * SAML attribute mapping configuration + * Re-export SSO types from @hawk.so/types */ -export interface SamlAttributeMapping { - /** - * Attribute name for email in SAML Assertion - * - * @example "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" - * to get email from XML like this: - * - * alice@company.com - * - */ - email: string; - - /** - * Attribute name for user name in SAML Assertion - */ - name?: string; -} - -/** - * SAML SSO configuration - */ -export interface SamlConfig { - /** - * IdP Entity ID. - * Used to validate "this response is intended for Hawk" - * @example "urn:hawk:tracker:saml" - */ - idpEntityId: string; - - /** - * SSO URL for redirecting user to IdP - * Used to redirect user to IdP for authentication - * @example "https://idp.example.com/sso" - */ - ssoUrl: string; - - /** - * X.509 certificate for signature verification - * @example "-----BEGIN CERTIFICATE-----\nMIIDYjCCAkqgAwIBAgI...END CERTIFICATE-----" - */ - x509Cert: string; - - /** - * Desired NameID format - * @example "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" - */ - nameIdFormat?: string; - - /** - * Attribute mapping configuration - * Used to extract user attributes from SAML Response - */ - attributeMapping: SamlAttributeMapping; -} - -/** - * SSO configuration for workspace - */ -export interface WorkspaceSsoConfig { - /** - * Is SSO enabled - */ - enabled: boolean; - - /** - * Is SSO enforced (only SSO login allowed) - * If true, login via email/password is not allowed - */ - enforced: boolean; - - /** - * SSO provider type - * Currently only SAML is supported. In future we can add other providers (OAuth 2, etc.) - */ - type: 'saml'; - - /** - * SAML-specific configuration. - * Got from IdP metadata. - */ - saml: SamlConfig; -} +export type { + SamlAttributeMapping, + SamlConfig, + WorkspaceSsoConfig, +} from '@hawk.so/types'; /** * Data extracted from SAML Response diff --git a/yarn.lock b/yarn.lock index f067fe46..cc167f8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -491,12 +491,12 @@ dependencies: "@types/mongodb" "^3.5.34" -"@hawk.so/types@^0.1.37": - version "0.1.37" - resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.1.37.tgz#e68d822957d86aac4fa1fdec7927a046ce0cf8c8" - integrity sha512-34C+TOWA5oJyOL3W+NXlSyY7u0OKkRu2+tIZ4jSJp0c1/5v+qpEPeo07FlOOHqDRRhMG4/2PAgQCronfF2qWPg== +"@hawk.so/types@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.4.0.tgz#76627aba4c253352e088a6c2b1358065908334ae" + integrity sha512-PiwrZn2xGIfCnFFapAZPSXC75cdMOUewV3LTMZijF9lBgUHI2fIgbcMdT65WAkDFArtQTYzoLhDvJMbhtEyRKA== dependencies: - "@types/mongodb" "^3.5.34" + bson "^7.0.0" "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" @@ -2045,6 +2045,11 @@ bson@^1.1.4: resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.6.tgz#fb819be9a60cd677e0853aee4ca712a785d6618a" integrity sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg== +bson@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/bson/-/bson-7.0.0.tgz#2ee7ac8296d61739a8d3d1799724a10d9f8afa8d" + integrity sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw== + buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" From 223a9461d3646679b49d4859eeb0597ae8bb854d Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 24 Dec 2025 19:08:51 +0300 Subject: [PATCH 04/33] tests for model and factory method added --- src/models/user.ts | 22 +-- test/models/user.test.ts | 233 +++++++++++++++++++++++++++++++ test/models/usersFactory.test.ts | 153 ++++++++++++++++++++ 3 files changed, 387 insertions(+), 21 deletions(-) create mode 100644 test/models/user.test.ts create mode 100644 test/models/usersFactory.test.ts diff --git a/src/models/user.ts b/src/models/user.ts index 2ed0e1ec..ff2703ab 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -456,7 +456,7 @@ export default class UserModel extends AbstractModel> await this.update( { _id: new ObjectId(this._id) }, - { $set: updateData } + updateData ); /** @@ -472,26 +472,6 @@ export default class UserModel extends AbstractModel> } } - /** - * Find user by SAML identity - * - * @param collection - users collection - * @param workspaceId - workspace ID - * @param samlId - NameID value from IdP - * @returns UserModel or null if not found - */ - public static async findBySamlIdentity( - collection: Collection, - workspaceId: string, - samlId: string - ): Promise { - const userData = await collection.findOne({ - [`identities.${workspaceId}.saml.id`]: samlId, - }); - - return userData ? new UserModel(userData) : null; - } - /** * Get SAML identity for workspace * diff --git a/test/models/user.test.ts b/test/models/user.test.ts new file mode 100644 index 00000000..68074482 --- /dev/null +++ b/test/models/user.test.ts @@ -0,0 +1,233 @@ +import '../../src/env-test'; +import UserModel from '../../src/models/user'; +import UsersFactory from '../../src/models/usersFactory'; +import * as mongo from '../../src/mongo'; +import DataLoaders from '../../src/dataLoaders'; + +beforeAll(async () => { + await mongo.setupConnections(); +}); + +describe('UserModel SSO identities', () => { + let usersFactory: UsersFactory; + let testUser: UserModel; + const testWorkspaceId = '507f1f77bcf86cd799439011'; + const testSamlId = 'test-saml-name-id-123'; + const testEmail = 'test-sso@example.com'; + + beforeEach(async () => { + /** + * Create factory instance + */ + usersFactory = new UsersFactory( + mongo.databases.hawk as any, + new DataLoaders(mongo.databases.hawk as any) + ); + + /** + * Create test user + */ + testUser = await usersFactory.create(testEmail, 'test-password-123'); + }); + + afterEach(async () => { + /** + * Clean up test user + */ + if (testUser && testUser.email) { + await usersFactory.deleteByEmail(testUser.email); + } + }); + + describe('linkSamlIdentity', () => { + it('should link SAML identity to user and update local state', async () => { + /** + * Initially, user should not have any identities + */ + expect(testUser.identities).toBeUndefined(); + + /** + * Link SAML identity + */ + await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); + + /** + * Check that local state is updated + */ + expect(testUser.identities).toBeDefined(); + expect(testUser.identities![testWorkspaceId]).toBeDefined(); + expect(testUser.identities![testWorkspaceId].saml).toEqual({ + id: testSamlId, + email: testEmail, + }); + }); + + it('should persist SAML identity in database', async () => { + /** + * Link SAML identity + */ + await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); + + /** + * Reload user from database to verify persistence + */ + const reloadedUser = await usersFactory.findById(testUser._id.toString()); + + expect(reloadedUser).not.toBeNull(); + expect(reloadedUser!.identities).toBeDefined(); + expect(reloadedUser!.identities![testWorkspaceId]).toBeDefined(); + expect(reloadedUser!.identities![testWorkspaceId].saml).toEqual({ + id: testSamlId, + email: testEmail, + }); + }); + + it('should allow linking identities for multiple workspaces', async () => { + const workspaceId2 = '507f1f77bcf86cd799439012'; + const samlId2 = 'test-saml-name-id-456'; + const email2 = 'test-sso-2@example.com'; + + /** + * Link identity for first workspace + */ + await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); + + /** + * Link identity for second workspace + */ + await testUser.linkSamlIdentity(workspaceId2, samlId2, email2); + + /** + * Check that both identities are present + */ + expect(testUser.identities![testWorkspaceId].saml).toEqual({ + id: testSamlId, + email: testEmail, + }); + expect(testUser.identities![workspaceId2].saml).toEqual({ + id: samlId2, + email: email2, + }); + + /** + * Verify in database + */ + const reloadedUser = await usersFactory.findById(testUser._id.toString()); + expect(reloadedUser!.identities![testWorkspaceId].saml.id).toBe(testSamlId); + expect(reloadedUser!.identities![workspaceId2].saml.id).toBe(samlId2); + }); + + it('should update existing SAML identity for the same workspace', async () => { + const newSamlId = 'updated-saml-name-id-789'; + const newEmail = 'updated-email@example.com'; + + /** + * Link initial identity + */ + await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); + + /** + * Update identity for the same workspace + */ + await testUser.linkSamlIdentity(testWorkspaceId, newSamlId, newEmail); + + /** + * Check that identity is updated (not duplicated) + */ + expect(testUser.identities![testWorkspaceId].saml).toEqual({ + id: newSamlId, + email: newEmail, + }); + + /** + * Verify in database + */ + const reloadedUser = await usersFactory.findById(testUser._id.toString()); + expect(reloadedUser!.identities![testWorkspaceId].saml).toEqual({ + id: newSamlId, + email: newEmail, + }); + }); + }); + + describe('getSamlIdentity', () => { + it('should return null when identity does not exist', () => { + /** + * User without any identities + */ + const identity = testUser.getSamlIdentity(testWorkspaceId); + expect(identity).toBeNull(); + }); + + it('should return null for non-existent workspace', async () => { + /** + * Link identity for one workspace + */ + await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); + + /** + * Try to get identity for different workspace + */ + const nonExistentWorkspaceId = '507f1f77bcf86cd799439099'; + const identity = testUser.getSamlIdentity(nonExistentWorkspaceId); + expect(identity).toBeNull(); + }); + + it('should return SAML identity when it exists', async () => { + /** + * Link SAML identity + */ + await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); + + /** + * Get identity + */ + const identity = testUser.getSamlIdentity(testWorkspaceId); + + expect(identity).not.toBeNull(); + expect(identity).toEqual({ + id: testSamlId, + email: testEmail, + }); + }); + + it('should return correct identity for specific workspace when multiple exist', async () => { + const workspaceId2 = '507f1f77bcf86cd799439012'; + const samlId2 = 'test-saml-name-id-456'; + const email2 = 'test-sso-2@example.com'; + + /** + * Link identities for two workspaces + */ + await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); + await testUser.linkSamlIdentity(workspaceId2, samlId2, email2); + + /** + * Get identity for first workspace + */ + const identity1 = testUser.getSamlIdentity(testWorkspaceId); + expect(identity1).toEqual({ + id: testSamlId, + email: testEmail, + }); + + /** + * Get identity for second workspace + */ + const identity2 = testUser.getSamlIdentity(workspaceId2); + expect(identity2).toEqual({ + id: samlId2, + email: email2, + }); + }); + }); + +}); + +afterAll(async done => { + await mongo.mongoClients.hawk?.close(); + await mongo.mongoClients.events?.close(); + + done(); +}); + diff --git a/test/models/usersFactory.test.ts b/test/models/usersFactory.test.ts new file mode 100644 index 00000000..15cd0f1c --- /dev/null +++ b/test/models/usersFactory.test.ts @@ -0,0 +1,153 @@ +import '../../src/env-test'; +import UserModel from '../../src/models/user'; +import UsersFactory from '../../src/models/usersFactory'; +import * as mongo from '../../src/mongo'; +import DataLoaders from '../../src/dataLoaders'; + +beforeAll(async () => { + await mongo.setupConnections(); +}); + +describe('UsersFactory SSO identities', () => { + let usersFactory: UsersFactory; + let testUser: UserModel; + const testWorkspaceId = '507f1f77bcf86cd799439011'; + const testSamlId = 'test-saml-name-id-123'; + const testEmail = 'test-sso@example.com'; + + beforeEach(async () => { + /** + * Create factory instance + */ + usersFactory = new UsersFactory( + mongo.databases.hawk as any, + new DataLoaders(mongo.databases.hawk as any) + ); + + /** + * Create test user + */ + testUser = await usersFactory.create(testEmail, 'test-password-123'); + }); + + afterEach(async () => { + /** + * Clean up test user + */ + if (testUser && testUser.email) { + await usersFactory.deleteByEmail(testUser.email); + } + }); + + describe('findBySamlIdentity', () => { + it('should return null when user with SAML identity does not exist', async () => { + /** + * Try to find user with non-existent SAML identity + */ + const foundUser = await usersFactory.findBySamlIdentity( + testWorkspaceId, + 'non-existent-saml-id' + ); + + expect(foundUser).toBeNull(); + }); + + it('should find user by SAML identity', async () => { + /** + * Link SAML identity to test user + */ + await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); + + /** + * Find user by SAML identity using factory method + */ + const foundUser = await usersFactory.findBySamlIdentity( + testWorkspaceId, + testSamlId + ); + + expect(foundUser).not.toBeNull(); + expect(foundUser!._id.toString()).toBe(testUser._id.toString()); + expect(foundUser!.email).toBe(testEmail); + expect(foundUser!.identities![testWorkspaceId].saml).toEqual({ + id: testSamlId, + email: testEmail, + }); + }); + + it('should find correct user when multiple users have SAML identities', async () => { + /** + * Create second user with different SAML identity + */ + const user2Email = 'test-sso-2@example.com'; + const user2SamlId = 'test-saml-name-id-456'; + const user2 = await usersFactory.create(user2Email, 'test-password-456'); + + try { + /** + * Link identities for both users + */ + await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); + await user2.linkSamlIdentity(testWorkspaceId, user2SamlId, user2Email); + + /** + * Find first user by its SAML identity + */ + const foundUser1 = await usersFactory.findBySamlIdentity( + testWorkspaceId, + testSamlId + ); + + expect(foundUser1).not.toBeNull(); + expect(foundUser1!._id.toString()).toBe(testUser._id.toString()); + expect(foundUser1!.email).toBe(testEmail); + + /** + * Find second user by its SAML identity + */ + const foundUser2 = await usersFactory.findBySamlIdentity( + testWorkspaceId, + user2SamlId + ); + + expect(foundUser2).not.toBeNull(); + expect(foundUser2!._id.toString()).toBe(user2._id.toString()); + expect(foundUser2!.email).toBe(user2Email); + } finally { + /** + * Clean up second user + */ + if (user2 && user2.email) { + await usersFactory.deleteByEmail(user2.email); + } + } + }); + + it('should return null for different workspace even if SAML ID matches', async () => { + const workspaceId2 = '507f1f77bcf86cd799439012'; + + /** + * Link identity for first workspace + */ + await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); + + /** + * Try to find user by same SAML ID but different workspace + */ + const foundUser = await usersFactory.findBySamlIdentity( + workspaceId2, + testSamlId + ); + + expect(foundUser).toBeNull(); + }); + }); +}); + +afterAll(async done => { + await mongo.mongoClients.hawk?.close(); + await mongo.mongoClients.events?.close(); + + done(); +}); + From c7f9c29d676ed26916485af719e0ca6dbcca941b Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 24 Dec 2025 19:14:17 +0300 Subject: [PATCH 05/33] rm redunant test cases --- test/models/user.test.ts | 79 -------------------------------- test/models/usersFactory.test.ts | 48 ------------------- 2 files changed, 127 deletions(-) diff --git a/test/models/user.test.ts b/test/models/user.test.ts index 68074482..cc424a6a 100644 --- a/test/models/user.test.ts +++ b/test/models/user.test.ts @@ -82,41 +82,6 @@ describe('UserModel SSO identities', () => { }); }); - it('should allow linking identities for multiple workspaces', async () => { - const workspaceId2 = '507f1f77bcf86cd799439012'; - const samlId2 = 'test-saml-name-id-456'; - const email2 = 'test-sso-2@example.com'; - - /** - * Link identity for first workspace - */ - await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); - - /** - * Link identity for second workspace - */ - await testUser.linkSamlIdentity(workspaceId2, samlId2, email2); - - /** - * Check that both identities are present - */ - expect(testUser.identities![testWorkspaceId].saml).toEqual({ - id: testSamlId, - email: testEmail, - }); - expect(testUser.identities![workspaceId2].saml).toEqual({ - id: samlId2, - email: email2, - }); - - /** - * Verify in database - */ - const reloadedUser = await usersFactory.findById(testUser._id.toString()); - expect(reloadedUser!.identities![testWorkspaceId].saml.id).toBe(testSamlId); - expect(reloadedUser!.identities![workspaceId2].saml.id).toBe(samlId2); - }); - it('should update existing SAML identity for the same workspace', async () => { const newSamlId = 'updated-saml-name-id-789'; const newEmail = 'updated-email@example.com'; @@ -159,20 +124,6 @@ describe('UserModel SSO identities', () => { expect(identity).toBeNull(); }); - it('should return null for non-existent workspace', async () => { - /** - * Link identity for one workspace - */ - await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); - - /** - * Try to get identity for different workspace - */ - const nonExistentWorkspaceId = '507f1f77bcf86cd799439099'; - const identity = testUser.getSamlIdentity(nonExistentWorkspaceId); - expect(identity).toBeNull(); - }); - it('should return SAML identity when it exists', async () => { /** * Link SAML identity @@ -190,36 +141,6 @@ describe('UserModel SSO identities', () => { email: testEmail, }); }); - - it('should return correct identity for specific workspace when multiple exist', async () => { - const workspaceId2 = '507f1f77bcf86cd799439012'; - const samlId2 = 'test-saml-name-id-456'; - const email2 = 'test-sso-2@example.com'; - - /** - * Link identities for two workspaces - */ - await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); - await testUser.linkSamlIdentity(workspaceId2, samlId2, email2); - - /** - * Get identity for first workspace - */ - const identity1 = testUser.getSamlIdentity(testWorkspaceId); - expect(identity1).toEqual({ - id: testSamlId, - email: testEmail, - }); - - /** - * Get identity for second workspace - */ - const identity2 = testUser.getSamlIdentity(workspaceId2); - expect(identity2).toEqual({ - id: samlId2, - email: email2, - }); - }); }); }); diff --git a/test/models/usersFactory.test.ts b/test/models/usersFactory.test.ts index 15cd0f1c..8246b132 100644 --- a/test/models/usersFactory.test.ts +++ b/test/models/usersFactory.test.ts @@ -75,54 +75,6 @@ describe('UsersFactory SSO identities', () => { }); }); - it('should find correct user when multiple users have SAML identities', async () => { - /** - * Create second user with different SAML identity - */ - const user2Email = 'test-sso-2@example.com'; - const user2SamlId = 'test-saml-name-id-456'; - const user2 = await usersFactory.create(user2Email, 'test-password-456'); - - try { - /** - * Link identities for both users - */ - await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); - await user2.linkSamlIdentity(testWorkspaceId, user2SamlId, user2Email); - - /** - * Find first user by its SAML identity - */ - const foundUser1 = await usersFactory.findBySamlIdentity( - testWorkspaceId, - testSamlId - ); - - expect(foundUser1).not.toBeNull(); - expect(foundUser1!._id.toString()).toBe(testUser._id.toString()); - expect(foundUser1!.email).toBe(testEmail); - - /** - * Find second user by its SAML identity - */ - const foundUser2 = await usersFactory.findBySamlIdentity( - testWorkspaceId, - user2SamlId - ); - - expect(foundUser2).not.toBeNull(); - expect(foundUser2!._id.toString()).toBe(user2._id.toString()); - expect(foundUser2!.email).toBe(user2Email); - } finally { - /** - * Clean up second user - */ - if (user2 && user2.email) { - await usersFactory.deleteByEmail(user2.email); - } - } - }); - it('should return null for different workspace even if SAML ID matches', async () => { const workspaceId2 = '507f1f77bcf86cd799439012'; From 5892ea06f73f57e234af33fd34f7875ddd945efc Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 24 Dec 2025 19:17:25 +0300 Subject: [PATCH 06/33] Update .nvmrc --- .nvmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nvmrc b/.nvmrc index dc0bb0f4..8ef0a525 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v22.12.0 +v24.11.1 From 1ca5fb8e422686c22da300d4994a486849f28149 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 24 Dec 2025 20:14:22 +0300 Subject: [PATCH 07/33] Refactor SAML validation logic and add unit tests Moved SAML audience, recipient, and time condition validation functions from SamlService to a new utils module for better separation of concerns. Added comprehensive unit tests for these utility functions and for SAML service logic. Improved test data isolation by introducing a unique test string generator. Updated existing user and usersFactory tests to use the new generator and ensure test isolation. Also, prevented MongoDB metrics setup in test environments. --- src/metrics/mongodb.ts | 12 +++ src/sso/saml/service.ts | 47 +---------- src/sso/saml/utils.ts | 48 +++++++++++ test/models/user.test.ts | 48 +++++++---- test/models/usersFactory.test.ts | 123 +++++++++++++++------------ test/sso/saml/service.test.ts | 59 +++++++++++++ test/sso/saml/utils.test.ts | 138 +++++++++++++++++++++++++++++++ test/utils/testData.ts | 25 ++++++ 8 files changed, 385 insertions(+), 115 deletions(-) create mode 100644 test/sso/saml/service.test.ts create mode 100644 test/sso/saml/utils.test.ts create mode 100644 test/utils/testData.ts diff --git a/src/metrics/mongodb.ts b/src/metrics/mongodb.ts index 7f14b680..dcdab08a 100644 --- a/src/metrics/mongodb.ts +++ b/src/metrics/mongodb.ts @@ -260,6 +260,18 @@ function logCommandFailed(event: any): void { * @param client - MongoDB client to monitor */ export function setupMongoMetrics(client: MongoClient): void { + /** + * Skip setup in test environment + * Check NODE_ENV or if running under Jest + */ + if ( + process.env.NODE_ENV === 'test' || + process.env.NODE_ENV === 'e2e' || + typeof jest !== 'undefined' + ) { + return; + } + client.on('commandStarted', (event) => { storeCommandInfo(event); diff --git a/src/sso/saml/service.ts b/src/sso/saml/service.ts index f4bde797..4184967d 100644 --- a/src/sso/saml/service.ts +++ b/src/sso/saml/service.ts @@ -1,5 +1,6 @@ import { SamlConfig, SamlResponseData } from '../types'; import { SamlValidationError, SamlValidationErrorType } from './types'; +import { validateAudience, validateRecipient, validateTimeConditions } from './utils'; /** * Service for SAML SSO operations @@ -65,51 +66,5 @@ export default class SamlService { throw new Error('Not implemented'); } - /** - * Validate Audience value - * - * @param audience - audience value from SAML Assertion - * @returns true if audience matches SSO_SP_ENTITY_ID - */ - public validateAudience(audience: string): boolean { - const spEntityId = process.env.SSO_SP_ENTITY_ID; - - if (!spEntityId) { - throw new Error('SSO_SP_ENTITY_ID environment variable is not set'); - } - - return audience === spEntityId; - } - - /** - * Validate Recipient value - * - * @param recipient - recipient URL from SAML Assertion - * @param expectedAcsUrl - expected ACS URL - * @returns true if recipient matches expected ACS URL - */ - public validateRecipient(recipient: string, expectedAcsUrl: string): boolean { - return recipient === expectedAcsUrl; - } - - /** - * Validate time conditions (NotBefore and NotOnOrAfter) - * - * @param notBefore - NotBefore timestamp - * @param notOnOrAfter - NotOnOrAfter timestamp - * @param clockSkew - allowed clock skew in milliseconds (default: 2 minutes) - * @returns true if assertion is valid at current time - */ - public validateTimeConditions( - notBefore: Date, - notOnOrAfter: Date, - clockSkew: number = 2 * 60 * 1000 - ): boolean { - const now = Date.now(); - const notBeforeTime = notBefore.getTime() - clockSkew; - const notOnOrAfterTime = notOnOrAfter.getTime() + clockSkew; - - return now >= notBeforeTime && now < notOnOrAfterTime; - } } diff --git a/src/sso/saml/utils.ts b/src/sso/saml/utils.ts index 32d90290..4d9bfd68 100644 --- a/src/sso/saml/utils.ts +++ b/src/sso/saml/utils.ts @@ -33,3 +33,51 @@ export function isValidPemCertificate(cert: string): boolean { return cert.includes('-----BEGIN CERTIFICATE-----') && cert.includes('-----END CERTIFICATE-----'); } +/** + * Validate Audience value + * + * @param audience - audience value from SAML Assertion + * @returns true if audience matches SSO_SP_ENTITY_ID + * @throws Error if SSO_SP_ENTITY_ID environment variable is not set + */ +export function validateAudience(audience: string): boolean { + const spEntityId = process.env.SSO_SP_ENTITY_ID; + + if (!spEntityId) { + throw new Error('SSO_SP_ENTITY_ID environment variable is not set'); + } + + return audience === spEntityId; +} + +/** + * Validate Recipient value + * + * @param recipient - recipient URL from SAML Assertion + * @param expectedAcsUrl - expected ACS URL + * @returns true if recipient matches expected ACS URL + */ +export function validateRecipient(recipient: string, expectedAcsUrl: string): boolean { + return recipient === expectedAcsUrl; +} + +/** + * Validate time conditions (NotBefore and NotOnOrAfter) + * + * @param notBefore - NotBefore timestamp + * @param notOnOrAfter - NotOnOrAfter timestamp + * @param clockSkew - allowed clock skew in milliseconds (default: 2 minutes) + * @returns true if assertion is valid at current time + */ +export function validateTimeConditions( + notBefore: Date, + notOnOrAfter: Date, + clockSkew: number = 2 * 60 * 1000 +): boolean { + const now = Date.now(); + const notBeforeTime = notBefore.getTime() - clockSkew; + const notOnOrAfterTime = notOnOrAfter.getTime() + clockSkew; + + return now >= notBeforeTime && now < notOnOrAfterTime; +} + diff --git a/test/models/user.test.ts b/test/models/user.test.ts index cc424a6a..aebee095 100644 --- a/test/models/user.test.ts +++ b/test/models/user.test.ts @@ -3,6 +3,7 @@ import UserModel from '../../src/models/user'; import UsersFactory from '../../src/models/usersFactory'; import * as mongo from '../../src/mongo'; import DataLoaders from '../../src/dataLoaders'; +import { generateTestString } from '../utils/testData'; beforeAll(async () => { await mongo.setupConnections(); @@ -11,36 +12,30 @@ beforeAll(async () => { describe('UserModel SSO identities', () => { let usersFactory: UsersFactory; let testUser: UserModel; - const testWorkspaceId = '507f1f77bcf86cd799439011'; - const testSamlId = 'test-saml-name-id-123'; - const testEmail = 'test-sso@example.com'; beforeEach(async () => { /** - * Create factory instance + * Create factory instance with fresh DataLoaders */ usersFactory = new UsersFactory( mongo.databases.hawk as any, new DataLoaders(mongo.databases.hawk as any) ); - - /** - * Create test user - */ - testUser = await usersFactory.create(testEmail, 'test-password-123'); }); afterEach(async () => { - /** - * Clean up test user - */ - if (testUser && testUser.email) { + if (testUser?.email) { await usersFactory.deleteByEmail(testUser.email); } }); describe('linkSamlIdentity', () => { it('should link SAML identity to user and update local state', async () => { + const testWorkspaceId = '507f1f77bcf86cd799439011'; + const testSamlId = generateTestString('model-link'); + const testEmail = generateTestString('model-test-sso@example.com'); + + testUser = await usersFactory.create(testEmail, 'test-password-123'); /** * Initially, user should not have any identities */ @@ -63,6 +58,11 @@ describe('UserModel SSO identities', () => { }); it('should persist SAML identity in database', async () => { + const testWorkspaceId = '507f1f77bcf86cd799439011'; + const testSamlId = generateTestString('model-persist'); + const testEmail = generateTestString('model-test-sso@example.com'); + + testUser = await usersFactory.create(testEmail, 'test-password-123'); /** * Link SAML identity */ @@ -83,13 +83,21 @@ describe('UserModel SSO identities', () => { }); it('should update existing SAML identity for the same workspace', async () => { - const newSamlId = 'updated-saml-name-id-789'; + const testWorkspaceId = '507f1f77bcf86cd799439011'; + const testEmail = generateTestString('model-test-sso@example.com'); + testUser = await usersFactory.create(testEmail, 'test-password-123'); + + /** + * Use unique SAML IDs for this test + */ + const initialSamlId = generateTestString('initial-identity'); + const newSamlId = generateTestString('updated-identity'); const newEmail = 'updated-email@example.com'; /** * Link initial identity */ - await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); + await testUser.linkSamlIdentity(testWorkspaceId, initialSamlId, testEmail); /** * Update identity for the same workspace @@ -116,7 +124,10 @@ describe('UserModel SSO identities', () => { }); describe('getSamlIdentity', () => { - it('should return null when identity does not exist', () => { + it('should return null when identity does not exist', async () => { + const testWorkspaceId = '507f1f77bcf86cd799439011'; + const testEmail = generateTestString('model-test-sso@example.com'); + testUser = await usersFactory.create(testEmail, 'test-password-123'); /** * User without any identities */ @@ -125,6 +136,11 @@ describe('UserModel SSO identities', () => { }); it('should return SAML identity when it exists', async () => { + const testWorkspaceId = '507f1f77bcf86cd799439011'; + const testSamlId = generateTestString('model-get'); + const testEmail = generateTestString('model-test-sso@example.com'); + + testUser = await usersFactory.create(testEmail, 'test-password-123'); /** * Link SAML identity */ diff --git a/test/models/usersFactory.test.ts b/test/models/usersFactory.test.ts index 8246b132..7f5e13d1 100644 --- a/test/models/usersFactory.test.ts +++ b/test/models/usersFactory.test.ts @@ -1,97 +1,114 @@ import '../../src/env-test'; -import UserModel from '../../src/models/user'; import UsersFactory from '../../src/models/usersFactory'; import * as mongo from '../../src/mongo'; import DataLoaders from '../../src/dataLoaders'; +import { generateTestString } from '../utils/testData'; beforeAll(async () => { await mongo.setupConnections(); }); describe('UsersFactory SSO identities', () => { - let usersFactory: UsersFactory; - let testUser: UserModel; - const testWorkspaceId = '507f1f77bcf86cd799439011'; - const testSamlId = 'test-saml-name-id-123'; - const testEmail = 'test-sso@example.com'; - - beforeEach(async () => { - /** - * Create factory instance - */ - usersFactory = new UsersFactory( + const createUsersFactory = (): UsersFactory => { + return new UsersFactory( mongo.databases.hawk as any, new DataLoaders(mongo.databases.hawk as any) ); - - /** - * Create test user - */ - testUser = await usersFactory.create(testEmail, 'test-password-123'); - }); - - afterEach(async () => { - /** - * Clean up test user - */ - if (testUser && testUser.email) { - await usersFactory.deleteByEmail(testUser.email); - } - }); + }; describe('findBySamlIdentity', () => { it('should return null when user with SAML identity does not exist', async () => { + const usersFactory = createUsersFactory(); + const testWorkspaceId = '507f1f77bcf86cd799439011'; + /** + * Use unique SAML ID to avoid conflicts with other tests + */ + const uniqueSamlId = generateTestString('non-existent'); + /** * Try to find user with non-existent SAML identity */ const foundUser = await usersFactory.findBySamlIdentity( testWorkspaceId, - 'non-existent-saml-id' + uniqueSamlId ); expect(foundUser).toBeNull(); }); it('should find user by SAML identity', async () => { + const usersFactory = createUsersFactory(); + const testWorkspaceId = '507f1f77bcf86cd799439011'; + const testEmail = generateTestString('factory-test-sso@example.com'); /** - * Link SAML identity to test user + * Use unique SAML ID for this specific test */ - await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); + const uniqueSamlId = generateTestString('find-test'); /** - * Find user by SAML identity using factory method + * Create test user for this test and ensure cleanup */ - const foundUser = await usersFactory.findBySamlIdentity( - testWorkspaceId, - testSamlId - ); - - expect(foundUser).not.toBeNull(); - expect(foundUser!._id.toString()).toBe(testUser._id.toString()); - expect(foundUser!.email).toBe(testEmail); - expect(foundUser!.identities![testWorkspaceId].saml).toEqual({ - id: testSamlId, - email: testEmail, - }); + const testUser = await usersFactory.create(testEmail, 'test-password-123'); + + try { + /** + * Link SAML identity to test user + */ + await testUser.linkSamlIdentity(testWorkspaceId, uniqueSamlId, testEmail); + + /** + * Find user by SAML identity using factory method + */ + const foundUser = await usersFactory.findBySamlIdentity( + testWorkspaceId, + uniqueSamlId + ); + + expect(foundUser).not.toBeNull(); + expect(foundUser!._id.toString()).toBe(testUser._id.toString()); + expect(foundUser!.email).toBe(testEmail); + expect(foundUser!.identities![testWorkspaceId].saml).toEqual({ + id: uniqueSamlId, + email: testEmail, + }); + } finally { + await usersFactory.deleteByEmail(testEmail); + } }); it('should return null for different workspace even if SAML ID matches', async () => { + const usersFactory = createUsersFactory(); + const testWorkspaceId = '507f1f77bcf86cd799439011'; const workspaceId2 = '507f1f77bcf86cd799439012'; - + const testEmail = generateTestString('factory-test-sso@example.com'); /** - * Link identity for first workspace + * Use unique SAML ID for this specific test */ - await testUser.linkSamlIdentity(testWorkspaceId, testSamlId, testEmail); + const uniqueSamlId = generateTestString('workspace-test'); /** - * Try to find user by same SAML ID but different workspace + * Create test user for this test */ - const foundUser = await usersFactory.findBySamlIdentity( - workspaceId2, - testSamlId - ); - - expect(foundUser).toBeNull(); + const testUser = await usersFactory.create(testEmail, 'test-password-123'); + + try { + /** + * Link identity for first workspace + */ + await testUser.linkSamlIdentity(testWorkspaceId, uniqueSamlId, testEmail); + + /** + * Try to find user by same SAML ID but different workspace + */ + const foundUser = await usersFactory.findBySamlIdentity( + workspaceId2, + uniqueSamlId + ); + + expect(foundUser).toBeNull(); + } finally { + await usersFactory.deleteByEmail(testEmail); + } }); }); }); diff --git a/test/sso/saml/service.test.ts b/test/sso/saml/service.test.ts new file mode 100644 index 00000000..c4272145 --- /dev/null +++ b/test/sso/saml/service.test.ts @@ -0,0 +1,59 @@ +import '../../../src/env-test'; +import SamlService from '../../../src/sso/saml/service'; +import { SamlConfig } from '../../../src/sso/types'; + +describe('SamlService', () => { + let samlService: SamlService; + const testWorkspaceId = '507f1f77bcf86cd799439011'; + const testAcsUrl = 'https://api.example.com/auth/sso/saml/507f1f77bcf86cd799439011/acs'; + const testRelayState = 'test-relay-state-123'; + + /** + * Test SAML configuration + */ + const testSamlConfig: SamlConfig = { + idpEntityId: 'urn:test:idp', + ssoUrl: 'https://idp.example.com/sso', + x509Cert: '-----BEGIN CERTIFICATE-----\nTEST_CERTIFICATE\n-----END CERTIFICATE-----', + nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + attributeMapping: { + email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name', + }, + }; + + beforeEach(() => { + samlService = new SamlService(); + }); + + describe('generateAuthnRequest', () => { + /** + * TODO: Add tests for: + * 1. Should generate valid AuthnRequest with correct structure + * 2. Should include correct ACS URL + * 3. Should include correct SP Entity ID + * 4. Should return unique request ID + * 5. Should return base64-encoded request + */ + }); + + describe('validateAndParseResponse', () => { + /** + * TODO: Add tests for: + * 1. Should parse valid SAML Response + * 2. Should extract NameID correctly + * 3. Should extract email using attributeMapping + * 4. Should extract name using attributeMapping (if available) + * 5. Should validate signature + * 6. Should validate Audience (uses validateAudience from utils) + * 7. Should validate Recipient (uses validateRecipient from utils) + * 8. Should validate InResponseTo + * 9. Should validate time conditions (uses validateTimeConditions from utils) + * 10. Should throw error for invalid signature + * 11. Should throw error for invalid Audience + * 12. Should throw error for invalid Recipient + * 13. Should throw error for expired assertion + */ + }); +}); + diff --git a/test/sso/saml/utils.test.ts b/test/sso/saml/utils.test.ts new file mode 100644 index 00000000..99a2a52f --- /dev/null +++ b/test/sso/saml/utils.test.ts @@ -0,0 +1,138 @@ +import '../../../src/env-test'; +import { validateAudience, validateRecipient, validateTimeConditions } from '../../../src/sso/saml/utils'; + +describe('SAML Utils', () => { + describe('validateAudience', () => { + const originalEnv = process.env.SSO_SP_ENTITY_ID; + + afterEach(() => { + /** + * Restore original env value + */ + if (originalEnv) { + process.env.SSO_SP_ENTITY_ID = originalEnv; + } else { + /** + * Use Reflect.deleteProperty to avoid TypeScript error with delete operator + */ + Reflect.deleteProperty(process.env, 'SSO_SP_ENTITY_ID'); + } + }); + + it('should return true when audience matches SSO_SP_ENTITY_ID', () => { + process.env.SSO_SP_ENTITY_ID = 'urn:hawk:tracker:saml'; + const result = validateAudience('urn:hawk:tracker:saml'); + expect(result).toBe(true); + }); + + it('should return false when audience does not match SSO_SP_ENTITY_ID', () => { + process.env.SSO_SP_ENTITY_ID = 'urn:hawk:tracker:saml'; + const result = validateAudience('urn:different:entity'); + expect(result).toBe(false); + }); + + it('should throw error when SSO_SP_ENTITY_ID is not set', () => { + /** + * Use Reflect.deleteProperty to avoid TypeScript error with delete operator + */ + Reflect.deleteProperty(process.env, 'SSO_SP_ENTITY_ID'); + expect(() => { + validateAudience('urn:hawk:tracker:saml'); + }).toThrow('SSO_SP_ENTITY_ID environment variable is not set'); + }); + }); + + describe('validateRecipient', () => { + it('should return true when recipient matches expected ACS URL', () => { + const recipient = 'https://api.example.com/auth/sso/saml/workspace123/acs'; + const expectedAcsUrl = 'https://api.example.com/auth/sso/saml/workspace123/acs'; + const result = validateRecipient(recipient, expectedAcsUrl); + expect(result).toBe(true); + }); + + it('should return false when recipient does not match expected ACS URL', () => { + const recipient = 'https://api.example.com/auth/sso/saml/workspace123/acs'; + const expectedAcsUrl = 'https://api.example.com/auth/sso/saml/workspace456/acs'; + const result = validateRecipient(recipient, expectedAcsUrl); + expect(result).toBe(false); + }); + }); + + describe('validateTimeConditions', () => { + /** + * Mock Date.now() for time-based tests using jest.spyOn + */ + let dateNowSpy: jest.SpyInstance; + + afterEach(() => { + if (dateNowSpy) { + dateNowSpy.mockRestore(); + } + }); + + it('should return true when assertion is valid (current time is between NotBefore and NotOnOrAfter)', () => { + const notBefore = new Date('2025-01-01T00:00:00Z'); + const notOnOrAfter = new Date('2025-01-01T01:00:00Z'); + const currentTime = new Date('2025-01-01T00:30:00Z').getTime(); + + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(currentTime); + const result = validateTimeConditions(notBefore, notOnOrAfter); + expect(result).toBe(true); + }); + + it('should return false when assertion is expired (current time is after NotOnOrAfter)', () => { + const notBefore = new Date('2025-01-01T00:00:00Z'); + const notOnOrAfter = new Date('2025-01-01T01:00:00Z'); + const currentTime = new Date('2025-01-01T01:30:00Z').getTime(); + + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(currentTime); + const result = validateTimeConditions(notBefore, notOnOrAfter); + expect(result).toBe(false); + }); + + it('should return false when assertion is not yet valid (current time is before NotBefore)', () => { + const notBefore = new Date('2025-01-01T00:00:00Z'); + const notOnOrAfter = new Date('2025-01-01T01:00:00Z'); + const currentTime = new Date('2024-12-31T23:30:00Z').getTime(); + + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(currentTime); + const result = validateTimeConditions(notBefore, notOnOrAfter); + expect(result).toBe(false); + }); + + it('should account for clock skew', () => { + const notBefore = new Date('2025-01-01T00:00:00Z'); + const notOnOrAfter = new Date('2025-01-01T01:00:00Z'); + /** + * Current time is 1 minute before NotBefore, but with 2 minute clock skew it should be valid + */ + const currentTime = new Date('2024-12-31T23:59:00Z').getTime(); + const clockSkew = 2 * 60 * 1000; // 2 minutes + + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(currentTime); + const result = validateTimeConditions(notBefore, notOnOrAfter, clockSkew); + expect(result).toBe(true); + }); + + it('should account for clock skew when assertion is expired', () => { + const notBefore = new Date('2025-01-01T00:00:00Z'); + const notOnOrAfter = new Date('2025-01-01T01:00:00Z'); + /** + * Current time is 1 minute after NotOnOrAfter, but with 2 minute clock skew it should still be valid + * (clock skew extends the window: notOnOrAfterTime = 01:00:00 + 2min = 01:02:00) + */ + const currentTime = new Date('2025-01-01T01:01:00Z').getTime(); + const clockSkew = 2 * 60 * 1000; // 2 minutes + + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(currentTime); + const result = validateTimeConditions(notBefore, notOnOrAfter, clockSkew); + /** + * With clock skew: notOnOrAfterTime = 2025-01-01T01:00:00Z + 2min = 2025-01-01T01:02:00Z + * Current time = 2025-01-01T01:01:00Z + * Should be valid + */ + expect(result).toBe(true); + }); + }); +}); + diff --git a/test/utils/testData.ts b/test/utils/testData.ts new file mode 100644 index 00000000..4eb4a4ae --- /dev/null +++ b/test/utils/testData.ts @@ -0,0 +1,25 @@ +/** + * Generic test data generators. + * + * Keep these helpers narrowly scoped and named by intent to avoid mixing concerns + * (e.g. do not use SAML ID generator for emails). + */ + +/** + * Generates a unique test string. + * + * Useful when tests run in parallel and share the same DB: unique values prevent + * collisions and accidental cross-test matches. + * + * Format: `{prefix}-{timestamp}-{random}` + * + * @example const testEmail = generateTestString('factory-test-sso@example.com'); + */ +export function generateTestString(prefix: string): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 9); + + return `${prefix}-${timestamp}-${random}`; +} + + From 77d55e98cd79f9784982682cbfbbb70d20ce92e6 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 24 Dec 2025 20:18:32 +0300 Subject: [PATCH 08/33] rm try-catch from tests --- test/models/usersFactory.test.ts | 105 ++++++++++++++++++------------- 1 file changed, 61 insertions(+), 44 deletions(-) diff --git a/test/models/usersFactory.test.ts b/test/models/usersFactory.test.ts index 7f5e13d1..bc6fbfc6 100644 --- a/test/models/usersFactory.test.ts +++ b/test/models/usersFactory.test.ts @@ -9,6 +9,9 @@ beforeAll(async () => { }); describe('UsersFactory SSO identities', () => { + let usersFactory: UsersFactory; + let emailsToCleanup: string[] = []; + const createUsersFactory = (): UsersFactory => { return new UsersFactory( mongo.databases.hawk as any, @@ -16,9 +19,31 @@ describe('UsersFactory SSO identities', () => { ); }; + beforeEach(() => { + usersFactory = createUsersFactory(); + emailsToCleanup = []; + }); + + afterEach(async () => { + /** + * Cleanup only data created by this test. + * Do NOT drop/delete whole collections: tests can run in parallel across files. + */ + const uniqueEmails = Array.from(new Set(emailsToCleanup)); + + for (const email of uniqueEmails) { + try { + await usersFactory.deleteByEmail(email); + } catch { + /** + * Ignore cleanup errors (e.g. already deleted by the test itself) + */ + } + } + }); + describe('findBySamlIdentity', () => { it('should return null when user with SAML identity does not exist', async () => { - const usersFactory = createUsersFactory(); const testWorkspaceId = '507f1f77bcf86cd799439011'; /** * Use unique SAML ID to avoid conflicts with other tests @@ -37,7 +62,6 @@ describe('UsersFactory SSO identities', () => { }); it('should find user by SAML identity', async () => { - const usersFactory = createUsersFactory(); const testWorkspaceId = '507f1f77bcf86cd799439011'; const testEmail = generateTestString('factory-test-sso@example.com'); /** @@ -46,38 +70,34 @@ describe('UsersFactory SSO identities', () => { const uniqueSamlId = generateTestString('find-test'); /** - * Create test user for this test and ensure cleanup + * Create test user for this test */ const testUser = await usersFactory.create(testEmail, 'test-password-123'); + emailsToCleanup.push(testEmail); - try { - /** - * Link SAML identity to test user - */ - await testUser.linkSamlIdentity(testWorkspaceId, uniqueSamlId, testEmail); + /** + * Link SAML identity to test user + */ + await testUser.linkSamlIdentity(testWorkspaceId, uniqueSamlId, testEmail); - /** - * Find user by SAML identity using factory method - */ - const foundUser = await usersFactory.findBySamlIdentity( - testWorkspaceId, - uniqueSamlId - ); - - expect(foundUser).not.toBeNull(); - expect(foundUser!._id.toString()).toBe(testUser._id.toString()); - expect(foundUser!.email).toBe(testEmail); - expect(foundUser!.identities![testWorkspaceId].saml).toEqual({ - id: uniqueSamlId, - email: testEmail, - }); - } finally { - await usersFactory.deleteByEmail(testEmail); - } + /** + * Find user by SAML identity using factory method + */ + const foundUser = await usersFactory.findBySamlIdentity( + testWorkspaceId, + uniqueSamlId + ); + + expect(foundUser).not.toBeNull(); + expect(foundUser!._id.toString()).toBe(testUser._id.toString()); + expect(foundUser!.email).toBe(testEmail); + expect(foundUser!.identities![testWorkspaceId].saml).toEqual({ + id: uniqueSamlId, + email: testEmail, + }); }); it('should return null for different workspace even if SAML ID matches', async () => { - const usersFactory = createUsersFactory(); const testWorkspaceId = '507f1f77bcf86cd799439011'; const workspaceId2 = '507f1f77bcf86cd799439012'; const testEmail = generateTestString('factory-test-sso@example.com'); @@ -90,25 +110,22 @@ describe('UsersFactory SSO identities', () => { * Create test user for this test */ const testUser = await usersFactory.create(testEmail, 'test-password-123'); + emailsToCleanup.push(testEmail); - try { - /** - * Link identity for first workspace - */ - await testUser.linkSamlIdentity(testWorkspaceId, uniqueSamlId, testEmail); + /** + * Link identity for first workspace + */ + await testUser.linkSamlIdentity(testWorkspaceId, uniqueSamlId, testEmail); - /** - * Try to find user by same SAML ID but different workspace - */ - const foundUser = await usersFactory.findBySamlIdentity( - workspaceId2, - uniqueSamlId - ); - - expect(foundUser).toBeNull(); - } finally { - await usersFactory.deleteByEmail(testEmail); - } + /** + * Try to find user by same SAML ID but different workspace + */ + const foundUser = await usersFactory.findBySamlIdentity( + workspaceId2, + uniqueSamlId + ); + + expect(foundUser).toBeNull(); }); }); }); From 61fea8eb671fae942b84a4f9fdcc54e2b7d6f0e9 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 24 Dec 2025 21:47:25 +0300 Subject: [PATCH 09/33] Refactor SAML response validation to use node-saml Replaces custom SAML assertion validation logic with @node-saml/node-saml for signature, audience, and time validation. Updates error handling to map node-saml errors to SamlValidationError types, adds fallback error type, and removes now-unnecessary utility functions and tests. Extends and improves test coverage for SAML response parsing, error cases, and attribute extraction. --- src/sso/saml/service.ts | 174 ++++++++++++++++++++-- src/sso/saml/types.ts | 5 + src/sso/saml/utils.ts | 58 -------- test/sso/saml/service.test.ts | 273 +++++++++++++++++++++++++++++++--- test/sso/saml/utils.test.ts | 159 ++++++-------------- 5 files changed, 461 insertions(+), 208 deletions(-) diff --git a/src/sso/saml/service.ts b/src/sso/saml/service.ts index 4184967d..f1705358 100644 --- a/src/sso/saml/service.ts +++ b/src/sso/saml/service.ts @@ -1,6 +1,7 @@ +import { SAML, SamlConfig as NodeSamlConfig, Profile } from '@node-saml/node-saml'; import { SamlConfig, SamlResponseData } from '../types'; import { SamlValidationError, SamlValidationErrorType } from './types'; -import { validateAudience, validateRecipient, validateTimeConditions } from './utils'; +import { extractAttribute } from './utils'; /** * Service for SAML SSO operations @@ -40,31 +41,172 @@ export default class SamlService { * @param workspaceId - workspace ID * @param acsUrl - expected Assertion Consumer Service URL * @param samlConfig - SAML configuration + * @param expectedRequestId - optional expected InResponseTo value (if provided, validates that response matches) * @returns parsed SAML response data + * @throws SamlValidationError if validation fails */ public async validateAndParseResponse( samlResponse: string, workspaceId: string, acsUrl: string, - samlConfig: SamlConfig + samlConfig: SamlConfig, + expectedRequestId?: string ): Promise { + const saml = this.createSamlInstance(acsUrl, samlConfig); + + let profile: Profile; + + try { + /** + * node-saml validates: + * - XML signature using x509Cert + * - Audience (via idpIssuer option) + * - Time conditions (NotBefore, NotOnOrAfter with clock skew) + */ + const result = await saml.validatePostResponseAsync({ + SAMLResponse: samlResponse, + }); + + if (!result.profile) { + throw new SamlValidationError( + SamlValidationErrorType.INVALID_SIGNATURE, + 'SAML response validation failed: no profile returned' + ); + } + + profile = result.profile; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown SAML validation error'; + + /** + * Determine specific error type based on error message + */ + if (message.includes('signature')) { + throw new SamlValidationError( + SamlValidationErrorType.INVALID_SIGNATURE, + `SAML signature validation failed: ${message}` + ); + } + + if (message.includes('expired') || message.includes('NotOnOrAfter') || message.includes('NotBefore')) { + throw new SamlValidationError( + SamlValidationErrorType.EXPIRED_ASSERTION, + `SAML assertion time validation failed: ${message}` + ); + } + + if (message.includes('audience') || message.includes('Audience')) { + throw new SamlValidationError( + SamlValidationErrorType.INVALID_AUDIENCE, + `SAML audience validation failed: ${message}` + ); + } + + /** + * Fallback for unknown error types + * Note: Error classification relies on message text which may change between library versions + */ + throw new SamlValidationError( + SamlValidationErrorType.VALIDATION_FAILED, + `SAML validation failed: ${message}` + ); + } + /** - * @todo Implement using @node-saml/node-saml - * - * This method should: - * 1. Decode base64 SAML Response - * 2. Validate XML signature using x509Cert - * 3. Validate Audience (should match SSO_SP_ENTITY_ID) - * 4. Validate Recipient (should match acsUrl) - * 5. Validate InResponseTo (should match saved AuthnRequest ID) - * 6. Validate time conditions (NotBefore, NotOnOrAfter) - * 7. Extract NameID - * 8. Extract email using attributeMapping - * 9. Extract name using attributeMapping (if available) - * 10. Return parsed data + * Extract NameID (Profile type defines nameID as required string) */ - throw new Error('Not implemented'); + const nameId = profile.nameID; + + if (!nameId) { + throw new SamlValidationError( + SamlValidationErrorType.INVALID_NAME_ID, + 'SAML response does not contain NameID' + ); + } + + /** + * Extract InResponseTo and validate if expectedRequestId provided + * Profile uses index signature [attributeName: string]: unknown for additional properties + */ + const inResponseTo = profile.inResponseTo as string | undefined; + + if (expectedRequestId && inResponseTo !== expectedRequestId) { + throw new SamlValidationError( + SamlValidationErrorType.INVALID_IN_RESPONSE_TO, + `InResponseTo mismatch: expected ${expectedRequestId}, got ${inResponseTo}`, + { expected: expectedRequestId, received: inResponseTo } + ); + } + + /** + * Extract attributes from profile + * node-saml puts SAML attributes directly on the profile object via index signature + */ + const attributes = profile as unknown as Record; + + /** + * Extract email using attributeMapping + */ + const email = extractAttribute(attributes, samlConfig.attributeMapping.email); + + if (!email) { + throw new SamlValidationError( + SamlValidationErrorType.MISSING_EMAIL, + `Email attribute not found in SAML response. Expected attribute: ${samlConfig.attributeMapping.email}`, + { attributeMapping: samlConfig.attributeMapping } + ); + } + + /** + * Extract name using attributeMapping (optional) + */ + let name: string | undefined; + + if (samlConfig.attributeMapping.name) { + name = extractAttribute(attributes, samlConfig.attributeMapping.name); + } + + return { + nameId, + email, + name, + inResponseTo, + }; } + /** + * Create node-saml SAML instance with given configuration + * + * @param acsUrl - Assertion Consumer Service URL + * @param samlConfig - SAML configuration from workspace + * @returns configured SAML instance + */ + private createSamlInstance(acsUrl: string, samlConfig: SamlConfig): SAML { + const spEntityId = process.env.SSO_SP_ENTITY_ID; + + if (!spEntityId) { + throw new Error('SSO_SP_ENTITY_ID environment variable is not set'); + } + + const options: NodeSamlConfig = { + callbackUrl: acsUrl, + entryPoint: samlConfig.ssoUrl, + issuer: spEntityId, + idpIssuer: samlConfig.idpEntityId, + idpCert: samlConfig.x509Cert, + wantAssertionsSigned: true, + wantAuthnResponseSigned: false, + /** + * Allow 2 minutes clock skew for time validation + */ + acceptedClockSkewMs: 2 * 60 * 1000, + }; + + if (samlConfig.nameIdFormat) { + options.identifierFormat = samlConfig.nameIdFormat; + } + + return new SAML(options); + } } diff --git a/src/sso/saml/types.ts b/src/sso/saml/types.ts index d4666556..b57c0b4f 100644 --- a/src/sso/saml/types.ts +++ b/src/sso/saml/types.ts @@ -14,6 +14,11 @@ export enum SamlValidationErrorType { EXPIRED_ASSERTION = 'EXPIRED_ASSERTION', INVALID_NAME_ID = 'INVALID_NAME_ID', MISSING_EMAIL = 'MISSING_EMAIL', + /** + * Generic validation error when specific type cannot be determined + * Used as fallback when library error messages don't match known patterns + */ + VALIDATION_FAILED = 'VALIDATION_FAILED', } /** diff --git a/src/sso/saml/utils.ts b/src/sso/saml/utils.ts index 4d9bfd68..3ad65430 100644 --- a/src/sso/saml/utils.ts +++ b/src/sso/saml/utils.ts @@ -23,61 +23,3 @@ export function extractAttribute(attributes: Record, return undefined; } -/** - * Validate PEM certificate format - * - * @param cert - certificate string - * @returns true if certificate appears to be valid PEM format - */ -export function isValidPemCertificate(cert: string): boolean { - return cert.includes('-----BEGIN CERTIFICATE-----') && cert.includes('-----END CERTIFICATE-----'); -} - -/** - * Validate Audience value - * - * @param audience - audience value from SAML Assertion - * @returns true if audience matches SSO_SP_ENTITY_ID - * @throws Error if SSO_SP_ENTITY_ID environment variable is not set - */ -export function validateAudience(audience: string): boolean { - const spEntityId = process.env.SSO_SP_ENTITY_ID; - - if (!spEntityId) { - throw new Error('SSO_SP_ENTITY_ID environment variable is not set'); - } - - return audience === spEntityId; -} - -/** - * Validate Recipient value - * - * @param recipient - recipient URL from SAML Assertion - * @param expectedAcsUrl - expected ACS URL - * @returns true if recipient matches expected ACS URL - */ -export function validateRecipient(recipient: string, expectedAcsUrl: string): boolean { - return recipient === expectedAcsUrl; -} - -/** - * Validate time conditions (NotBefore and NotOnOrAfter) - * - * @param notBefore - NotBefore timestamp - * @param notOnOrAfter - NotOnOrAfter timestamp - * @param clockSkew - allowed clock skew in milliseconds (default: 2 minutes) - * @returns true if assertion is valid at current time - */ -export function validateTimeConditions( - notBefore: Date, - notOnOrAfter: Date, - clockSkew: number = 2 * 60 * 1000 -): boolean { - const now = Date.now(); - const notBeforeTime = notBefore.getTime() - clockSkew; - const notOnOrAfterTime = notOnOrAfter.getTime() + clockSkew; - - return now >= notBeforeTime && now < notOnOrAfterTime; -} - diff --git a/test/sso/saml/service.test.ts b/test/sso/saml/service.test.ts index c4272145..2ec6ebb6 100644 --- a/test/sso/saml/service.test.ts +++ b/test/sso/saml/service.test.ts @@ -1,12 +1,18 @@ import '../../../src/env-test'; import SamlService from '../../../src/sso/saml/service'; import { SamlConfig } from '../../../src/sso/types'; +import { SamlValidationError, SamlValidationErrorType } from '../../../src/sso/saml/types'; +import * as nodeSaml from '@node-saml/node-saml'; + +/** + * Mock @node-saml/node-saml + */ +jest.mock('@node-saml/node-saml'); describe('SamlService', () => { let samlService: SamlService; const testWorkspaceId = '507f1f77bcf86cd799439011'; const testAcsUrl = 'https://api.example.com/auth/sso/saml/507f1f77bcf86cd799439011/acs'; - const testRelayState = 'test-relay-state-123'; /** * Test SAML configuration @@ -22,10 +28,24 @@ describe('SamlService', () => { }, }; + const mockSamlInstance = { + validatePostResponseAsync: jest.fn(), + }; + beforeEach(() => { + jest.clearAllMocks(); + (nodeSaml.SAML as jest.Mock).mockImplementation(() => mockSamlInstance); + process.env.SSO_SP_ENTITY_ID = 'urn:hawk:tracker:saml'; samlService = new SamlService(); }); + afterEach(() => { + /** + * Restore env + */ + Reflect.deleteProperty(process.env, 'SSO_SP_ENTITY_ID'); + }); + describe('generateAuthnRequest', () => { /** * TODO: Add tests for: @@ -38,22 +58,239 @@ describe('SamlService', () => { }); describe('validateAndParseResponse', () => { - /** - * TODO: Add tests for: - * 1. Should parse valid SAML Response - * 2. Should extract NameID correctly - * 3. Should extract email using attributeMapping - * 4. Should extract name using attributeMapping (if available) - * 5. Should validate signature - * 6. Should validate Audience (uses validateAudience from utils) - * 7. Should validate Recipient (uses validateRecipient from utils) - * 8. Should validate InResponseTo - * 9. Should validate time conditions (uses validateTimeConditions from utils) - * 10. Should throw error for invalid signature - * 11. Should throw error for invalid Audience - * 12. Should throw error for invalid Recipient - * 13. Should throw error for expired assertion - */ + const testSamlResponse = 'base64EncodedSamlResponse'; + + it('should parse valid SAML Response and extract user data', async () => { + /** + * Mock successful SAML validation with all required attributes + */ + mockSamlInstance.validatePostResponseAsync.mockResolvedValue({ + profile: { + nameID: 'user-name-id-123', + inResponseTo: '_request-id-123', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': 'user@example.com', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name': 'John Doe', + }, + }); + + const result = await samlService.validateAndParseResponse( + testSamlResponse, + testWorkspaceId, + testAcsUrl, + testSamlConfig + ); + + expect(result).toEqual({ + nameId: 'user-name-id-123', + email: 'user@example.com', + name: 'John Doe', + inResponseTo: '_request-id-123', + }); + }); + + it('should work without optional name attribute', async () => { + mockSamlInstance.validatePostResponseAsync.mockResolvedValue({ + profile: { + nameID: 'user-name-id-123', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': 'user@example.com', + /** + * name attribute is not provided by IdP + */ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name': undefined, + }, + }); + + const result = await samlService.validateAndParseResponse( + testSamlResponse, + testWorkspaceId, + testAcsUrl, + testSamlConfig + ); + + expect(result.nameId).toBe('user-name-id-123'); + expect(result.email).toBe('user@example.com'); + expect(result.name).toBeUndefined(); + }); + + it('should throw INVALID_SIGNATURE error when signature validation fails', async () => { + mockSamlInstance.validatePostResponseAsync.mockRejectedValue( + new Error('Invalid signature') + ); + + await expect( + samlService.validateAndParseResponse( + testSamlResponse, + testWorkspaceId, + testAcsUrl, + testSamlConfig + ) + ).rejects.toThrow(SamlValidationError); + + try { + await samlService.validateAndParseResponse( + testSamlResponse, + testWorkspaceId, + testAcsUrl, + testSamlConfig + ); + } catch (error) { + expect(error).toBeInstanceOf(SamlValidationError); + expect((error as SamlValidationError).type).toBe(SamlValidationErrorType.INVALID_SIGNATURE); + } + }); + + it('should throw EXPIRED_ASSERTION error when assertion is expired', async () => { + mockSamlInstance.validatePostResponseAsync.mockRejectedValue( + new Error('SAML assertion NotOnOrAfter condition not met') + ); + + const promise = samlService.validateAndParseResponse( + testSamlResponse, + testWorkspaceId, + testAcsUrl, + testSamlConfig + ); + + await expect(promise).rejects.toThrow(SamlValidationError); + await expect(promise).rejects.toMatchObject({ + type: SamlValidationErrorType.EXPIRED_ASSERTION, + }); + }); + + it('should throw INVALID_AUDIENCE error when audience validation fails', async () => { + mockSamlInstance.validatePostResponseAsync.mockRejectedValue( + new Error('SAML Audience not valid') + ); + + const promise = samlService.validateAndParseResponse( + testSamlResponse, + testWorkspaceId, + testAcsUrl, + testSamlConfig + ); + + await expect(promise).rejects.toThrow(SamlValidationError); + await expect(promise).rejects.toMatchObject({ + type: SamlValidationErrorType.INVALID_AUDIENCE, + }); + }); + + it('should throw INVALID_NAME_ID error when NameID is missing', async () => { + mockSamlInstance.validatePostResponseAsync.mockResolvedValue({ + profile: { + /** + * No nameID in profile + */ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': 'user@example.com', + }, + }); + + const promise = samlService.validateAndParseResponse( + testSamlResponse, + testWorkspaceId, + testAcsUrl, + testSamlConfig + ); + + await expect(promise).rejects.toThrow(SamlValidationError); + await expect(promise).rejects.toMatchObject({ + type: SamlValidationErrorType.INVALID_NAME_ID, + }); + }); + + it('should throw MISSING_EMAIL error when email attribute is not found', async () => { + mockSamlInstance.validatePostResponseAsync.mockResolvedValue({ + profile: { + nameID: 'user-name-id-123', + /** + * Wrong attribute name, email attribute is missing + */ + 'wrong-attribute': 'user@example.com', + }, + }); + + const promise = samlService.validateAndParseResponse( + testSamlResponse, + testWorkspaceId, + testAcsUrl, + testSamlConfig + ); + + await expect(promise).rejects.toThrow(SamlValidationError); + await expect(promise).rejects.toMatchObject({ + type: SamlValidationErrorType.MISSING_EMAIL, + }); + }); + + it('should throw INVALID_IN_RESPONSE_TO when InResponseTo does not match expected request ID', async () => { + mockSamlInstance.validatePostResponseAsync.mockResolvedValue({ + profile: { + nameID: 'user-name-id-123', + inResponseTo: '_different-request-id', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': 'user@example.com', + }, + }); + + const promise = samlService.validateAndParseResponse( + testSamlResponse, + testWorkspaceId, + testAcsUrl, + testSamlConfig, + '_expected-request-id' + ); + + await expect(promise).rejects.toThrow(SamlValidationError); + await expect(promise).rejects.toMatchObject({ + type: SamlValidationErrorType.INVALID_IN_RESPONSE_TO, + context: { + expected: '_expected-request-id', + received: '_different-request-id', + }, + }); + }); + + it('should validate InResponseTo when expectedRequestId is provided', async () => { + mockSamlInstance.validatePostResponseAsync.mockResolvedValue({ + profile: { + nameID: 'user-name-id-123', + inResponseTo: '_expected-request-id', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': 'user@example.com', + }, + }); + + const result = await samlService.validateAndParseResponse( + testSamlResponse, + testWorkspaceId, + testAcsUrl, + testSamlConfig, + '_expected-request-id' + ); + + expect(result.inResponseTo).toBe('_expected-request-id'); + }); + + it('should handle email as array attribute', async () => { + mockSamlInstance.validatePostResponseAsync.mockResolvedValue({ + profile: { + nameID: 'user-name-id-123', + /** + * Some IdPs return attributes as arrays + */ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': ['user@example.com', 'secondary@example.com'], + }, + }); + + const result = await samlService.validateAndParseResponse( + testSamlResponse, + testWorkspaceId, + testAcsUrl, + testSamlConfig + ); + + /** + * Should use first email from array + */ + expect(result.email).toBe('user@example.com'); + }); }); }); - diff --git a/test/sso/saml/utils.test.ts b/test/sso/saml/utils.test.ts index 99a2a52f..d9d0b51b 100644 --- a/test/sso/saml/utils.test.ts +++ b/test/sso/saml/utils.test.ts @@ -1,138 +1,65 @@ import '../../../src/env-test'; -import { validateAudience, validateRecipient, validateTimeConditions } from '../../../src/sso/saml/utils'; +import { extractAttribute } from '../../../src/sso/saml/utils'; describe('SAML Utils', () => { - describe('validateAudience', () => { - const originalEnv = process.env.SSO_SP_ENTITY_ID; + describe('extractAttribute', () => { + it('should return string value when attribute is a string', () => { + const attributes = { + email: 'user@example.com', + }; - afterEach(() => { - /** - * Restore original env value - */ - if (originalEnv) { - process.env.SSO_SP_ENTITY_ID = originalEnv; - } else { - /** - * Use Reflect.deleteProperty to avoid TypeScript error with delete operator - */ - Reflect.deleteProperty(process.env, 'SSO_SP_ENTITY_ID'); - } - }); - - it('should return true when audience matches SSO_SP_ENTITY_ID', () => { - process.env.SSO_SP_ENTITY_ID = 'urn:hawk:tracker:saml'; - const result = validateAudience('urn:hawk:tracker:saml'); - expect(result).toBe(true); - }); - - it('should return false when audience does not match SSO_SP_ENTITY_ID', () => { - process.env.SSO_SP_ENTITY_ID = 'urn:hawk:tracker:saml'; - const result = validateAudience('urn:different:entity'); - expect(result).toBe(false); - }); - - it('should throw error when SSO_SP_ENTITY_ID is not set', () => { - /** - * Use Reflect.deleteProperty to avoid TypeScript error with delete operator - */ - Reflect.deleteProperty(process.env, 'SSO_SP_ENTITY_ID'); - expect(() => { - validateAudience('urn:hawk:tracker:saml'); - }).toThrow('SSO_SP_ENTITY_ID environment variable is not set'); - }); - }); + const result = extractAttribute(attributes, 'email'); - describe('validateRecipient', () => { - it('should return true when recipient matches expected ACS URL', () => { - const recipient = 'https://api.example.com/auth/sso/saml/workspace123/acs'; - const expectedAcsUrl = 'https://api.example.com/auth/sso/saml/workspace123/acs'; - const result = validateRecipient(recipient, expectedAcsUrl); - expect(result).toBe(true); + expect(result).toBe('user@example.com'); }); - it('should return false when recipient does not match expected ACS URL', () => { - const recipient = 'https://api.example.com/auth/sso/saml/workspace123/acs'; - const expectedAcsUrl = 'https://api.example.com/auth/sso/saml/workspace456/acs'; - const result = validateRecipient(recipient, expectedAcsUrl); - expect(result).toBe(false); - }); - }); + it('should return first element when attribute is an array', () => { + const attributes = { + email: ['primary@example.com', 'secondary@example.com'], + }; - describe('validateTimeConditions', () => { - /** - * Mock Date.now() for time-based tests using jest.spyOn - */ - let dateNowSpy: jest.SpyInstance; + const result = extractAttribute(attributes, 'email'); - afterEach(() => { - if (dateNowSpy) { - dateNowSpy.mockRestore(); - } + expect(result).toBe('primary@example.com'); }); - it('should return true when assertion is valid (current time is between NotBefore and NotOnOrAfter)', () => { - const notBefore = new Date('2025-01-01T00:00:00Z'); - const notOnOrAfter = new Date('2025-01-01T01:00:00Z'); - const currentTime = new Date('2025-01-01T00:30:00Z').getTime(); - - dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(currentTime); - const result = validateTimeConditions(notBefore, notOnOrAfter); - expect(result).toBe(true); - }); + it('should return undefined when attribute does not exist', () => { + const attributes = { + name: 'John Doe', + }; - it('should return false when assertion is expired (current time is after NotOnOrAfter)', () => { - const notBefore = new Date('2025-01-01T00:00:00Z'); - const notOnOrAfter = new Date('2025-01-01T01:00:00Z'); - const currentTime = new Date('2025-01-01T01:30:00Z').getTime(); + const result = extractAttribute(attributes, 'email'); - dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(currentTime); - const result = validateTimeConditions(notBefore, notOnOrAfter); - expect(result).toBe(false); + expect(result).toBeUndefined(); }); - it('should return false when assertion is not yet valid (current time is before NotBefore)', () => { - const notBefore = new Date('2025-01-01T00:00:00Z'); - const notOnOrAfter = new Date('2025-01-01T01:00:00Z'); - const currentTime = new Date('2024-12-31T23:30:00Z').getTime(); + it('should return undefined when array is empty', () => { + const attributes = { + email: [] as string[], + }; - dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(currentTime); - const result = validateTimeConditions(notBefore, notOnOrAfter); - expect(result).toBe(false); - }); - - it('should account for clock skew', () => { - const notBefore = new Date('2025-01-01T00:00:00Z'); - const notOnOrAfter = new Date('2025-01-01T01:00:00Z'); - /** - * Current time is 1 minute before NotBefore, but with 2 minute clock skew it should be valid - */ - const currentTime = new Date('2024-12-31T23:59:00Z').getTime(); - const clockSkew = 2 * 60 * 1000; // 2 minutes + const result = extractAttribute(attributes, 'email'); - dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(currentTime); - const result = validateTimeConditions(notBefore, notOnOrAfter, clockSkew); - expect(result).toBe(true); + expect(result).toBeUndefined(); }); - it('should account for clock skew when assertion is expired', () => { - const notBefore = new Date('2025-01-01T00:00:00Z'); - const notOnOrAfter = new Date('2025-01-01T01:00:00Z'); - /** - * Current time is 1 minute after NotOnOrAfter, but with 2 minute clock skew it should still be valid - * (clock skew extends the window: notOnOrAfterTime = 01:00:00 + 2min = 01:02:00) - */ - const currentTime = new Date('2025-01-01T01:01:00Z').getTime(); - const clockSkew = 2 * 60 * 1000; // 2 minutes - - dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(currentTime); - const result = validateTimeConditions(notBefore, notOnOrAfter, clockSkew); - /** - * With clock skew: notOnOrAfterTime = 2025-01-01T01:00:00Z + 2min = 2025-01-01T01:02:00Z - * Current time = 2025-01-01T01:01:00Z - * Should be valid - */ - expect(result).toBe(true); + it('should work with SAML-style attribute names', () => { + const attributes = { + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': 'user@example.com', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name': 'John Doe', + }; + + const email = extractAttribute( + attributes, + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' + ); + const name = extractAttribute( + attributes, + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name' + ); + + expect(email).toBe('user@example.com'); + expect(name).toBe('John Doe'); }); }); }); - From 1369b2c06a45073f4a19c45bf707500ad7f23422 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 24 Dec 2025 22:01:07 +0300 Subject: [PATCH 10/33] Implement SAML AuthnRequest generation and tests Added logic to generate SAML AuthnRequest using node-saml, extract the request ID from the encoded request, and handle errors. Updated and expanded unit tests to cover successful generation, error cases, and correct invocation of SAML library methods. --- src/sso/saml/service.ts | 61 ++++++++++++++++--- test/sso/saml/service.test.ts | 108 ++++++++++++++++++++++++++++++++-- 2 files changed, 154 insertions(+), 15 deletions(-) diff --git a/src/sso/saml/service.ts b/src/sso/saml/service.ts index f1705358..5438ddaf 100644 --- a/src/sso/saml/service.ts +++ b/src/sso/saml/service.ts @@ -12,7 +12,7 @@ export default class SamlService { * * @param workspaceId - workspace ID * @param acsUrl - Assertion Consumer Service URL - * @param relayState - relay state to pass through + * @param relayState - context of user returning (url + relay state id) * @param samlConfig - SAML configuration * @returns AuthnRequest ID and encoded SAML request */ @@ -22,16 +22,59 @@ export default class SamlService { relayState: string, samlConfig: SamlConfig ): Promise<{ requestId: string; encodedRequest: string }> { + const saml = this.createSamlInstance(acsUrl, samlConfig); + /** - * @todo Implement using @node-saml/node-saml - * - * This method should: - * 1. Generate unique AuthnRequest ID - * 2. Create SAML AuthnRequest XML - * 3. Encode it as base64 - * 4. Return both requestId and encoded request + * Generate AuthnRequest message + * node-saml returns object with SAMLRequest (deflated + base64 encoded) */ - throw new Error('Not implemented'); + const authorizeMessage = await saml.getAuthorizeMessageAsync(relayState, undefined, {}); + + const encodedRequest = authorizeMessage.SAMLRequest as string; + + if (!encodedRequest) { + throw new Error('Failed to generate SAML AuthnRequest'); + } + + /** + * Extract request ID from the generated request + * node-saml generates unique ID internally using generateUniqueId option + * We need to decode and parse to get the ID for InResponseTo validation + */ + const requestId = this.extractRequestIdFromEncodedRequest(encodedRequest); + + return { + requestId, + encodedRequest, + }; + } + + /** + * Extract request ID from encoded SAML AuthnRequest + * + * @param encodedRequest - deflated and base64 encoded SAML request + * @returns request ID + */ + private extractRequestIdFromEncodedRequest(encodedRequest: string): string { + const zlib = require('zlib'); + + /** + * Decode base64 and inflate + */ + const decoded = Buffer.from(encodedRequest, 'base64'); + const inflated = zlib.inflateRawSync(decoded).toString('utf-8'); + + /** + * Extract ID attribute from AuthnRequest XML + * Format: + */ + const idMatch = inflated.match(/ID="([^"]+)"/); + + if (!idMatch || !idMatch[1]) { + throw new Error('Failed to extract request ID from AuthnRequest'); + } + + return idMatch[1]; } /** diff --git a/test/sso/saml/service.test.ts b/test/sso/saml/service.test.ts index 2ec6ebb6..425442e5 100644 --- a/test/sso/saml/service.test.ts +++ b/test/sso/saml/service.test.ts @@ -30,6 +30,7 @@ describe('SamlService', () => { const mockSamlInstance = { validatePostResponseAsync: jest.fn(), + getAuthorizeMessageAsync: jest.fn(), }; beforeEach(() => { @@ -47,14 +48,109 @@ describe('SamlService', () => { }); describe('generateAuthnRequest', () => { + const testRelayState = 'test-relay-state-123'; + /** - * TODO: Add tests for: - * 1. Should generate valid AuthnRequest with correct structure - * 2. Should include correct ACS URL - * 3. Should include correct SP Entity ID - * 4. Should return unique request ID - * 5. Should return base64-encoded request + * Helper to create a mock SAML AuthnRequest (deflated + base64 encoded) */ + function createMockEncodedRequest(requestId: string): string { + const zlib = require('zlib'); + const xml = ` + + urn:hawk:tracker:saml + `; + + const deflated = zlib.deflateRawSync(xml); + + return deflated.toString('base64'); + } + + it('should generate AuthnRequest and return request ID', async () => { + const mockRequestId = '_test-request-id-12345'; + const mockEncodedRequest = createMockEncodedRequest(mockRequestId); + + mockSamlInstance.getAuthorizeMessageAsync.mockResolvedValue({ + SAMLRequest: mockEncodedRequest, + RelayState: testRelayState, + }); + + const result = await samlService.generateAuthnRequest( + testWorkspaceId, + testAcsUrl, + testRelayState, + testSamlConfig + ); + + expect(result.requestId).toBe(mockRequestId); + expect(result.encodedRequest).toBe(mockEncodedRequest); + }); + + it('should call getAuthorizeMessageAsync with correct relay state', async () => { + const mockRequestId = '_another-request-id'; + const mockEncodedRequest = createMockEncodedRequest(mockRequestId); + + mockSamlInstance.getAuthorizeMessageAsync.mockResolvedValue({ + SAMLRequest: mockEncodedRequest, + }); + + await samlService.generateAuthnRequest( + testWorkspaceId, + testAcsUrl, + testRelayState, + testSamlConfig + ); + + expect(mockSamlInstance.getAuthorizeMessageAsync).toHaveBeenCalledWith( + testRelayState, + undefined, + {} + ); + }); + + it('should throw error when SAMLRequest is not returned', async () => { + mockSamlInstance.getAuthorizeMessageAsync.mockResolvedValue({ + /** + * No SAMLRequest in response + */ + }); + + await expect( + samlService.generateAuthnRequest( + testWorkspaceId, + testAcsUrl, + testRelayState, + testSamlConfig + ) + ).rejects.toThrow('Failed to generate SAML AuthnRequest'); + }); + + it('should throw error when request ID cannot be extracted', async () => { + const zlib = require('zlib'); + /** + * Invalid XML without ID attribute + */ + const invalidXml = 'no id here'; + const deflated = zlib.deflateRawSync(invalidXml); + const invalidEncodedRequest = deflated.toString('base64'); + + mockSamlInstance.getAuthorizeMessageAsync.mockResolvedValue({ + SAMLRequest: invalidEncodedRequest, + }); + + await expect( + samlService.generateAuthnRequest( + testWorkspaceId, + testAcsUrl, + testRelayState, + testSamlConfig + ) + ).rejects.toThrow('Failed to extract request ID from AuthnRequest'); + }); }); describe('validateAndParseResponse', () => { From 0725454174344ebcce1380cfdb44aa19df655964 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sun, 28 Dec 2025 17:26:49 +0300 Subject: [PATCH 11/33] SamlStateStore implemetation --- src/sso/saml/store.ts | 140 ++++++++++++++++++++------- test/sso/saml/store.test.ts | 184 ++++++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 33 deletions(-) create mode 100644 test/sso/saml/store.test.ts diff --git a/src/sso/saml/store.ts b/src/sso/saml/store.ts index 26d2c6f2..b2ca8121 100644 --- a/src/sso/saml/store.ts +++ b/src/sso/saml/store.ts @@ -1,27 +1,42 @@ -import { RelayStateData, AuthnRequestState } from './types'; +import { AuthnRequestState, RelayStateData } from './types'; /** * In-memory store for SAML state - * @todo Replace with Redis for production + * + * Stores temporary data needed for SAML authentication flow: + * - RelayState: maps state ID to return URL and workspace ID + * - AuthnRequests: maps request ID to workspace ID for InResponseTo validation + * + * @todo Replace with Redis for production (multi-instance support) */ class SamlStateStore { + private relayStates: Map = new Map(); + private authnRequests: Map = new Map(); + /** - * Map of relay state IDs to relay state data + * Time-to-live for stored state (5 minutes) */ - private relayStates: Map = new Map(); + private readonly TTL = 5 * 60 * 1000; /** - * Map of AuthnRequest IDs to AuthnRequest state + * Interval for cleanup of expired entries (1 minute) */ - private authnRequests: Map = new Map(); + private readonly CLEANUP_INTERVAL = 60 * 1000; /** - * Time-to-live for stored state (5 minutes) + * Cleanup timer reference */ - private readonly TTL = 5 * 60 * 1000; + private cleanupTimer: NodeJS.Timeout | null = null; + + constructor() { + this.startCleanupTimer(); + } /** - * Save relay state + * Save RelayState data + * + * @param stateId - unique state identifier (usually UUID) + * @param data - relay state data (returnUrl, workspaceId) */ public saveRelayState(stateId: string, data: { returnUrl: string; workspaceId: string }): void { this.relayStates.set(stateId, { @@ -31,7 +46,10 @@ class SamlStateStore { } /** - * Get relay state by ID + * Get and consume RelayState data + * + * @param stateId - state identifier + * @returns relay state data or null if not found/expired */ public getRelayState(stateId: string): { returnUrl: string; workspaceId: string } | null { const state = this.relayStates.get(stateId); @@ -40,16 +58,28 @@ class SamlStateStore { return null; } + /** + * Check expiration + */ if (Date.now() > state.expiresAt) { this.relayStates.delete(stateId); + return null; } + /** + * Consume (delete after use to prevent replay) + */ + this.relayStates.delete(stateId); + return { returnUrl: state.returnUrl, workspaceId: state.workspaceId }; } /** - * Save AuthnRequest state + * Save AuthnRequest for InResponseTo validation + * + * @param requestId - SAML AuthnRequest ID + * @param workspaceId - workspace ID */ public saveAuthnRequest(requestId: string, workspaceId: string): void { this.authnRequests.set(requestId, { @@ -60,58 +90,102 @@ class SamlStateStore { /** * Validate and consume AuthnRequest - * Returns true if request is valid and not expired, false otherwise - * Removes the request from storage after validation + * + * @param requestId - SAML AuthnRequest ID (from InResponseTo) + * @param workspaceId - expected workspace ID + * @returns true if request is valid and matches workspace */ public validateAndConsumeAuthnRequest(requestId: string, workspaceId: string): boolean { - const state = this.authnRequests.get(requestId); + const request = this.authnRequests.get(requestId); - if (!state) { + if (!request) { return false; } - if (Date.now() > state.expiresAt) { + /** + * Check expiration + */ + if (Date.now() > request.expiresAt) { this.authnRequests.delete(requestId); + return false; } - if (state.workspaceId !== workspaceId) { - this.authnRequests.delete(requestId); + /** + * Check workspace match + */ + if (request.workspaceId !== workspaceId) { return false; } /** - * Remove request after successful validation (prevent replay attacks) + * Consume (delete after use to prevent replay attacks) */ this.authnRequests.delete(requestId); + return true; } /** - * Clean up expired entries (can be called periodically) + * Start periodic cleanup of expired entries */ - public cleanup(): void { - const now = Date.now(); - + private startCleanupTimer(): void { /** - * Clean up expired relay states + * Don't start timer in test environment */ - for (const [id, state] of this.relayStates.entries()) { - if (now > state.expiresAt) { - this.relayStates.delete(id); - } + if (process.env.NODE_ENV === 'test') { + return; } + this.cleanupTimer = setInterval(() => { + this.cleanup(); + }, this.CLEANUP_INTERVAL); + /** - * Clean up expired AuthnRequests + * Don't prevent process from exiting */ - for (const [id, state] of this.authnRequests.entries()) { - if (now > state.expiresAt) { - this.authnRequests.delete(id); + this.cleanupTimer.unref(); + } + + /** + * Clean up expired entries + */ + private cleanup(): void { + const now = Date.now(); + + for (const [key, value] of this.relayStates) { + if (now > value.expiresAt) { + this.relayStates.delete(key); + } + } + + for (const [key, value] of this.authnRequests) { + if (now > value.expiresAt) { + this.authnRequests.delete(key); } } } + + /** + * Stop cleanup timer (for testing) + */ + public stopCleanupTimer(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + } + + /** + * Clear all stored state (for testing) + */ + public clear(): void { + this.relayStates.clear(); + this.authnRequests.clear(); + } } +/** + * Singleton instance + */ export default new SamlStateStore(); - diff --git a/test/sso/saml/store.test.ts b/test/sso/saml/store.test.ts new file mode 100644 index 00000000..2e8df375 --- /dev/null +++ b/test/sso/saml/store.test.ts @@ -0,0 +1,184 @@ +import '../../../src/env-test'; + +/** + * Import the store class directly to create fresh instances for each test + */ +jest.isolateModules(() => { + /** + * We need to test the store module in isolation + */ +}); + +describe('SamlStateStore', () => { + let SamlStateStore: typeof import('../../../src/sso/saml/store').default; + + beforeEach(() => { + /** + * Clear module cache and reimport to get fresh instance + */ + jest.resetModules(); + SamlStateStore = require('../../../src/sso/saml/store').default; + SamlStateStore.clear(); + }); + + afterEach(() => { + SamlStateStore.stopCleanupTimer(); + }); + + describe('RelayState', () => { + const testStateId = 'test-state-id-123'; + const testData = { + returnUrl: '/workspace/abc123', + workspaceId: '507f1f77bcf86cd799439011', + }; + + it('should save and retrieve RelayState', () => { + SamlStateStore.saveRelayState(testStateId, testData); + + const result = SamlStateStore.getRelayState(testStateId); + + expect(result).toEqual(testData); + }); + + it('should return null for non-existent RelayState', () => { + const result = SamlStateStore.getRelayState('non-existent-id'); + + expect(result).toBeNull(); + }); + + it('should consume (delete) RelayState after retrieval (prevent replay)', () => { + SamlStateStore.saveRelayState(testStateId, testData); + + /** + * First retrieval should return data + */ + const firstResult = SamlStateStore.getRelayState(testStateId); + expect(firstResult).toEqual(testData); + + /** + * Second retrieval should return null (consumed) + */ + const secondResult = SamlStateStore.getRelayState(testStateId); + expect(secondResult).toBeNull(); + }); + + it('should return null for expired RelayState', () => { + /** + * Mock Date.now to simulate expiration + */ + const originalDateNow = Date.now; + const startTime = 1000000000000; + + Date.now = jest.fn().mockReturnValue(startTime); + SamlStateStore.saveRelayState(testStateId, testData); + + /** + * Move time forward by 6 minutes (past 5 min TTL) + */ + Date.now = jest.fn().mockReturnValue(startTime + 6 * 60 * 1000); + const result = SamlStateStore.getRelayState(testStateId); + + expect(result).toBeNull(); + + /** + * Restore Date.now + */ + Date.now = originalDateNow; + }); + }); + + describe('AuthnRequest', () => { + const testRequestId = '_request-id-abc123'; + const testWorkspaceId = '507f1f77bcf86cd799439011'; + + it('should save and validate AuthnRequest', () => { + SamlStateStore.saveAuthnRequest(testRequestId, testWorkspaceId); + + const result = SamlStateStore.validateAndConsumeAuthnRequest( + testRequestId, + testWorkspaceId + ); + + expect(result).toBe(true); + }); + + it('should return false for non-existent AuthnRequest', () => { + const result = SamlStateStore.validateAndConsumeAuthnRequest( + 'non-existent-request', + testWorkspaceId + ); + + expect(result).toBe(false); + }); + + it('should return false for wrong workspace ID', () => { + SamlStateStore.saveAuthnRequest(testRequestId, testWorkspaceId); + + const result = SamlStateStore.validateAndConsumeAuthnRequest( + testRequestId, + 'different-workspace-id' + ); + + expect(result).toBe(false); + }); + + it('should consume (delete) AuthnRequest after validation (prevent replay)', () => { + SamlStateStore.saveAuthnRequest(testRequestId, testWorkspaceId); + + /** + * First validation should succeed + */ + const firstResult = SamlStateStore.validateAndConsumeAuthnRequest( + testRequestId, + testWorkspaceId + ); + expect(firstResult).toBe(true); + + /** + * Second validation should fail (consumed) + */ + const secondResult = SamlStateStore.validateAndConsumeAuthnRequest( + testRequestId, + testWorkspaceId + ); + expect(secondResult).toBe(false); + }); + + it('should return false for expired AuthnRequest', () => { + const originalDateNow = Date.now; + const startTime = 1000000000000; + + Date.now = jest.fn().mockReturnValue(startTime); + SamlStateStore.saveAuthnRequest(testRequestId, testWorkspaceId); + + /** + * Move time forward by 6 minutes (past 5 min TTL) + */ + Date.now = jest.fn().mockReturnValue(startTime + 6 * 60 * 1000); + const result = SamlStateStore.validateAndConsumeAuthnRequest( + testRequestId, + testWorkspaceId + ); + + expect(result).toBe(false); + + Date.now = originalDateNow; + }); + }); + + describe('clear', () => { + it('should clear all stored state', () => { + SamlStateStore.saveRelayState('state-1', { + returnUrl: '/test', + workspaceId: 'ws-1', + }); + SamlStateStore.saveAuthnRequest('request-1', 'ws-1'); + + SamlStateStore.clear(); + + expect(SamlStateStore.getRelayState('state-1')).toBeNull(); + expect(SamlStateStore.validateAndConsumeAuthnRequest('request-1', 'ws-1')).toBe(false); + }); + }); +}); + From 1963a5968e62d1ec9a78b045f53b1fdfe19818c1 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 7 Jan 2026 20:52:41 +0300 Subject: [PATCH 12/33] Implement SAML SSO controller and tests Added SAML SSO login and ACS endpoint logic to the controller, including user provisioning and session creation. Updated Jest config to use a dedicated test tsconfig. Added comprehensive tests for SAML controller behavior and created a test tsconfig.json. --- jest.config.js | 4 +- src/index.ts | 17 + src/sso/saml/controller.ts | 192 +++++++++- src/sso/saml/service.ts | 2 + test/sso/saml/controller.test.ts | 637 +++++++++++++++++++++++++++++++ test/tsconfig.json | 15 + 6 files changed, 862 insertions(+), 5 deletions(-) create mode 100644 test/sso/saml/controller.test.ts create mode 100644 test/tsconfig.json diff --git a/jest.config.js b/jest.config.js index 212d114a..1e7f792d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -24,7 +24,9 @@ module.exports = { * TypeScript support */ transform: { - '^.+\\.tsx?$': 'ts-jest', + '^.+\\.tsx?$': ['ts-jest', { + tsconfig: 'test/tsconfig.json', + }], }, /** diff --git a/src/index.ts b/src/index.ts index d84776b5..98f7a62e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ import { metricsMiddleware, createMetricsServer, graphqlMetricsPlugin } from './ import { requestLogger } from './utils/logger'; import ReleasesFactory from './models/releasesFactory'; import RedisHelper from './redisHelper'; +import { appendSsoRoutes } from './sso'; /** * Option to enable playground @@ -246,6 +247,22 @@ class HawkAPI { await redis.initialize(); + /** + * Setup shared factories for SSO routes + * SSO endpoints don't require per-request DataLoaders isolation, + * so we can reuse the same factories instance + * Created here to avoid duplication with createContext + */ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const ssoDataLoaders = new DataLoaders(mongo.databases.hawk!); + const ssoFactories = HawkAPI.setupFactories(ssoDataLoaders); + + /** + * Append SSO routes to Express app using shared factories + * Note: This must be called after database connections are established + */ + appendSsoRoutes(this.app, ssoFactories); + await this.server.start(); this.app.use(graphqlUploadExpress()); this.server.applyMiddleware({ app: this.app }); diff --git a/src/sso/saml/controller.ts b/src/sso/saml/controller.ts index 55256cd7..76c8ef20 100644 --- a/src/sso/saml/controller.ts +++ b/src/sso/saml/controller.ts @@ -1,7 +1,11 @@ import express from 'express'; +import { v4 as uuid } from 'uuid'; import SamlService from './service'; import samlStore from './store'; import { ContextFactories } from '../../types/graphql'; +import { SamlResponseData } from '../types'; +import WorkspaceModel from '../../models/workspace'; +import UserModel from '../../models/user'; /** * Controller for SAML SSO endpoints @@ -37,20 +41,200 @@ export default class SamlController { * Initiate SSO login (GET /auth/sso/saml/:workspaceId) */ public async initiateLogin(req: express.Request, res: express.Response): Promise { + const { workspaceId } = req.params; + const returnUrl = (req.query.returnUrl as string) || `/workspace/${workspaceId}`; + + /** + * 1. Check if workspace has SSO enabled + */ + const workspace = await this.factories.workspacesFactory.findById(workspaceId); + + if (!workspace || !workspace.sso?.enabled) { + res.status(400).json({ error: 'SSO is not enabled for this workspace' }); + return; + } + + /** + * 2. Compose Assertion Consumer Service URL + */ + const acsUrl = this.getAcsUrl(workspaceId); + const relayStateId = uuid(); + /** - * TODO: Implement according to specification + * 3. Save RelayState to temporary storage */ - throw new Error('Not implemented'); + samlStore.saveRelayState(relayStateId, { returnUrl, workspaceId }); + + /** + * 4. Generate AuthnRequest + */ + const { requestId, encodedRequest } = await this.samlService.generateAuthnRequest( + workspaceId, + acsUrl, + relayStateId, + workspace.sso.saml + ); + + /** + * 5. Save AuthnRequest ID for InResponseTo validation + */ + samlStore.saveAuthnRequest(requestId, workspaceId); + + /** + * 6. Redirect to IdP + */ + const redirectUrl = new URL(workspace.sso.saml.ssoUrl); + redirectUrl.searchParams.set('SAMLRequest', encodedRequest); + redirectUrl.searchParams.set('RelayState', relayStateId); + + res.redirect(redirectUrl.toString()); } /** * Handle ACS callback (POST /auth/sso/saml/:workspaceId/acs) */ public async handleAcs(req: express.Request, res: express.Response): Promise { + const { workspaceId } = req.params; + const samlResponse = req.body.SAMLResponse as string; + const relayStateId = req.body.RelayState as string; + + /** + * 1. Get workspace SSO configuration and check if SSO is enabled + */ + const workspace = await this.factories.workspacesFactory.findById(workspaceId); + + if (!workspace || !workspace.sso?.enabled) { + res.status(400).json({ error: 'SSO is not enabled' }); + return; + } + + /** + * 2. Validate and parse SAML Response + */ + const acsUrl = this.getAcsUrl(workspaceId); + + let samlData: SamlResponseData; + + try { + /** + * Validate and parse SAML Response + * Note: InResponseTo validation is done separately after parsing + */ + samlData = await this.samlService.validateAndParseResponse( + samlResponse, + workspaceId, + acsUrl, + workspace.sso.saml + ); + + /** + * Validate InResponseTo against stored AuthnRequest + */ + if (samlData.inResponseTo) { + const isValidRequest = samlStore.validateAndConsumeAuthnRequest( + samlData.inResponseTo, + workspaceId + ); + + if (!isValidRequest) { + res.status(400).json({ error: 'Invalid SAML response: InResponseTo validation failed' }); + return; + } + } + } catch (error) { + console.error('SAML validation error:', { + workspaceId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + res.status(400).json({ error: 'Invalid SAML response' }); + return; + } + + /** + * 3. Find or create user + */ + let user = await this.factories.usersFactory.findBySamlIdentity(workspaceId, samlData.nameId); + + if (!user) { + /** + * JIT provisioning or invite-only policy + */ + user = await this.handleUserProvisioning(workspaceId, samlData, workspace); + } + + /** + * 4. Get RelayState for return URL (before consuming) + * Note: RelayState is consumed after first use, so we need to get it before validation + */ + const relayState = samlStore.getRelayState(relayStateId); + const finalReturnUrl = relayState?.returnUrl || `/workspace/${workspaceId}`; + /** - * TODO: Implement according to specification + * 5. Create Hawk session */ - throw new Error('Not implemented'); + const tokens = await user.generateTokensPair(); + + /** + * 6. Redirect to Garage with tokens + */ + const frontendUrl = new URL(finalReturnUrl, process.env.GARAGE_URL || 'http://localhost:3000'); + frontendUrl.searchParams.set('access_token', tokens.accessToken); + frontendUrl.searchParams.set('refresh_token', tokens.refreshToken); + + res.redirect(frontendUrl.toString()); + } + + /** + * Handle user provisioning (JIT or invite-only) + * + * @param workspaceId - workspace ID + * @param samlData - parsed SAML response data + * @param workspace - workspace model + * @returns UserModel instance + */ + private async handleUserProvisioning( + workspaceId: string, + samlData: SamlResponseData, + workspace: WorkspaceModel + ): Promise { + /** + * Find user by email + */ + let user = await this.factories.usersFactory.findByEmail(samlData.email); + + if (!user) { + /** + * Create new user (JIT provisioning) + * Password is not set - only SSO login is allowed + */ + user = await this.factories.usersFactory.create(samlData.email, undefined, undefined); + } + + /** + * Link SAML identity to user + */ + await user.linkSamlIdentity(workspaceId, samlData.nameId, samlData.email); + + /** + * Check if user is a member of the workspace + */ + const member = await workspace.getMemberInfo(user._id.toString()); + + if (!member) { + /** + * Add user to workspace (JIT provisioning) + */ + await workspace.addMember(user._id.toString()); + await user.addWorkspace(workspaceId); + } else if (WorkspaceModel.isPendingMember(member)) { + /** + * Confirm pending membership + */ + await workspace.confirmMembership(user); + await user.confirmMembership(workspaceId); + } + + return user; } } diff --git a/src/sso/saml/service.ts b/src/sso/saml/service.ts index 5438ddaf..869ec952 100644 --- a/src/sso/saml/service.ts +++ b/src/sso/saml/service.ts @@ -10,6 +10,8 @@ export default class SamlService { /** * Generate SAML AuthnRequest * + * AuthnRequest - a SAML-message that Hawk sends to IdP to initiate auth process. + * * @param workspaceId - workspace ID * @param acsUrl - Assertion Consumer Service URL * @param relayState - context of user returning (url + relay state id) diff --git a/test/sso/saml/controller.test.ts b/test/sso/saml/controller.test.ts new file mode 100644 index 00000000..73f7945f --- /dev/null +++ b/test/sso/saml/controller.test.ts @@ -0,0 +1,637 @@ +import '../../../src/env-test'; +import { Request, Response } from 'express'; +import { ObjectId } from 'mongodb'; +import SamlController from '../../../src/sso/saml/controller'; +import { ContextFactories } from '../../../src/types/graphql'; +import { WorkspaceSsoConfig } from '../../../src/sso/types'; +import { WorkspaceDBScheme, UserDBScheme } from '@hawk.so/types'; +import SamlService from '../../../src/sso/saml/service'; +import samlStore from '../../../src/sso/saml/store'; +import * as mongo from '../../../src/mongo'; + +/** + * Mock dependencies + */ +jest.mock('../../../src/sso/saml/service'); + +/** + * Import models AFTER mongo setup to ensure databases.hawk is initialized + * This must be done after beforeAll sets up connections + */ +import WorkspaceModel from '../../../src/models/workspace'; +import UserModel from '../../../src/models/user'; + +beforeAll(async () => { + /** + * Ensure MONGO_HAWK_DB_URL is set from MONGO_URL (set by @shelf/jest-mongodb) + * This is a fallback in case setup.ts didn't run or MONGO_URL wasn't available then + */ + if (process.env.MONGO_URL && !process.env.MONGO_HAWK_DB_URL) { + process.env.MONGO_HAWK_DB_URL = process.env.MONGO_URL; + } + if (process.env.MONGO_URL && !process.env.MONGO_EVENTS_DB_URL) { + process.env.MONGO_EVENTS_DB_URL = process.env.MONGO_URL; + } + + await mongo.setupConnections(); + + /** + * Verify that databases are initialized + */ + if (!mongo.databases.hawk) { + throw new Error( + `Failed to initialize MongoDB connection for tests. ` + + `MONGO_URL: ${process.env.MONGO_URL}, ` + + `MONGO_HAWK_DB_URL: ${process.env.MONGO_HAWK_DB_URL}` + ); + } +}); + +describe('SamlController', () => { + let controller: SamlController; + let mockFactories: ContextFactories; + let mockWorkspacesFactory: any; + let mockUsersFactory: any; + let mockSamlService: jest.Mocked; + let mockReq: Partial; + let mockRes: Partial; + + const testWorkspaceId = new ObjectId().toString(); + const testUserId = new ObjectId().toString(); + const testSamlConfig: WorkspaceSsoConfig['saml'] = { + idpEntityId: 'urn:test:idp', + ssoUrl: 'https://idp.example.com/sso', + x509Cert: '-----BEGIN CERTIFICATE-----\nTEST_CERTIFICATE\n-----END CERTIFICATE-----', + nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + attributeMapping: { + email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name', + }, + }; + + /** + * Create mock workspace with SSO enabled + * Using partial mock object instead of real instance to avoid MongoDB connection issues in tests + */ + function createMockWorkspace(overrides?: Partial): Partial & { _id: ObjectId } { + const workspaceData: WorkspaceDBScheme = { + _id: new ObjectId(testWorkspaceId), + name: 'Test Workspace', + accountId: 'test-account-id', + balance: 0, + billingPeriodEventsCount: 0, + isBlocked: false, + lastChargeDate: new Date(), + tariffPlanId: new ObjectId(), + inviteHash: 'test-invite-hash', + subscriptionId: undefined, + sso: { + enabled: true, + enforced: false, + type: 'saml', + saml: testSamlConfig, + }, + ...overrides, + }; + + return { + ...workspaceData, + getMemberInfo: jest.fn(), + addMember: jest.fn(), + confirmMembership: jest.fn(), + } as any; + } + + /** + * Create mock user + * Using partial mock object instead of real instance to avoid MongoDB connection issues in tests + */ + function createMockUser(overrides?: Partial): Partial & { _id: ObjectId; email?: string } { + const userData: UserDBScheme = { + _id: new ObjectId(testUserId), + email: 'test@example.com', + notifications: { + channels: { + email: { + isEnabled: true, + endpoint: 'test@example.com', + minPeriod: 60, + }, + }, + whatToReceive: {} as any, + }, + ...overrides, + }; + + return { + ...userData, + linkSamlIdentity: jest.fn(), + addWorkspace: jest.fn(), + confirmMembership: jest.fn(), + generateTokensPair: jest.fn().mockResolvedValue({ + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + }), + } as any; + } + + beforeEach(() => { + /** + * Clear all mocks + */ + jest.clearAllMocks(); + samlStore.clear(); + + /** + * Setup environment variables + */ + process.env.API_URL = 'https://api.example.com'; + process.env.GARAGE_URL = 'https://garage.example.com'; + process.env.SSO_SP_ENTITY_ID = 'urn:hawk:tracker:saml'; + + /** + * Mock factories + */ + mockWorkspacesFactory = { + findById: jest.fn(), + }; + + mockUsersFactory = { + findBySamlIdentity: jest.fn(), + findByEmail: jest.fn(), + create: jest.fn(), + }; + + mockFactories = { + workspacesFactory: mockWorkspacesFactory as any, + usersFactory: mockUsersFactory as any, + projectsFactory: {} as any, + plansFactory: {} as any, + businessOperationsFactory: {} as any, + releasesFactory: {} as any, + }; + + /** + * Mock SamlService + */ + mockSamlService = { + generateAuthnRequest: jest.fn(), + validateAndParseResponse: jest.fn(), + } as any; + + (SamlService as jest.Mock).mockImplementation(() => mockSamlService); + + /** + * Create controller + */ + controller = new SamlController(mockFactories); + + /** + * Mock Express Request + */ + mockReq = { + params: {}, + query: {}, + body: {}, + }; + + /** + * Mock Express Response + */ + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + redirect: jest.fn().mockReturnThis(), + }; + }); + + afterEach(() => { + /** + * Clean up environment + */ + Reflect.deleteProperty(process.env, 'API_URL'); + Reflect.deleteProperty(process.env, 'GARAGE_URL'); + Reflect.deleteProperty(process.env, 'SSO_SP_ENTITY_ID'); + }); + + describe('initiateLogin', () => { + const testReturnUrl = '/workspace/test'; + + beforeEach(() => { + mockReq.params = { workspaceId: testWorkspaceId }; + mockReq.query = { returnUrl: testReturnUrl }; + }); + + it('should redirect to IdP with SAMLRequest and RelayState when SSO is enabled', async () => { + const workspace = createMockWorkspace(); + mockWorkspacesFactory.findById.mockResolvedValue(workspace); + + const mockRequestId = '_test-request-id-12345'; + const mockEncodedRequest = 'encoded-saml-request'; + mockSamlService.generateAuthnRequest.mockResolvedValue({ + requestId: mockRequestId, + encodedRequest: mockEncodedRequest, + }); + + await controller.initiateLogin(mockReq as Request, mockRes as Response); + + /** + * Verify workspace was fetched + */ + expect(mockWorkspacesFactory.findById).toHaveBeenCalledWith(testWorkspaceId); + + /** + * Verify AuthnRequest was generated + */ + expect(mockSamlService.generateAuthnRequest).toHaveBeenCalledWith( + testWorkspaceId, + expect.stringContaining(`/auth/sso/saml/${testWorkspaceId}/acs`), + expect.any(String), + testSamlConfig + ); + + /** + * Verify redirect to IdP with SAMLRequest and RelayState + */ + expect(mockRes.redirect).toHaveBeenCalledWith( + expect.stringContaining('https://idp.example.com/sso') // got from testSamlConfig.ssoUrl + ); + + const redirectUrl = new URL((mockRes.redirect as jest.Mock).mock.calls[0][0]); + expect(redirectUrl.searchParams.get('SAMLRequest')).toBe(mockEncodedRequest); + expect(redirectUrl.searchParams.get('RelayState')).toBeTruthy(); + + /** + * Verify AuthnRequest ID was saved by checking it can be validated + */ + expect(samlStore.validateAndConsumeAuthnRequest(mockRequestId, testWorkspaceId)).toBe(true); + }); + + it('should use default returnUrl when not provided', async () => { + const workspace = createMockWorkspace(); + mockWorkspacesFactory.findById.mockResolvedValue(workspace); + mockReq.query = {}; + + const mockRequestId = '_test-request-id-12345'; + mockSamlService.generateAuthnRequest.mockResolvedValue({ + requestId: mockRequestId, + encodedRequest: 'encoded-saml-request', + }); + + await controller.initiateLogin(mockReq as Request, mockRes as Response); + + /** + * Verify redirect contains RelayState + */ + const redirectCall = (mockRes.redirect as jest.Mock).mock.calls[0][0]; + const redirectUrl = new URL(redirectCall); + const relayStateId = redirectUrl.searchParams.get('RelayState'); + expect(relayStateId).toBeTruthy(); + + /** + * Verify that default returnUrl was saved in RelayState + * Default returnUrl is `/workspace/${workspaceId}` + */ + const relayState = samlStore.getRelayState(relayStateId!); + expect(relayState).not.toBeNull(); + expect(relayState?.returnUrl).toBe(`/workspace/${testWorkspaceId}`); + expect(relayState?.workspaceId).toBe(testWorkspaceId); + }); + + it('should return 400 error when workspace is not found', async () => { + mockWorkspacesFactory.findById.mockResolvedValue(null); + + await controller.initiateLogin(mockReq as Request, mockRes as Response); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.redirect).not.toHaveBeenCalled(); + }); + + it('should return 400 error when workspace exists but SSO is not configured', async () => { + const workspace = createMockWorkspace({ sso: undefined }); + mockWorkspacesFactory.findById.mockResolvedValue(workspace); + + await controller.initiateLogin(mockReq as Request, mockRes as Response); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'SSO is not enabled for this workspace', + }); + expect(mockRes.redirect).not.toHaveBeenCalled(); + }); + + it('should return 400 error when SSO is disabled', async () => { + const workspace = createMockWorkspace({ + sso: { + enabled: false, + enforced: false, + type: 'saml', + saml: testSamlConfig, + }, + }); + mockWorkspacesFactory.findById.mockResolvedValue(workspace); + + await controller.initiateLogin(mockReq as Request, mockRes as Response); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'SSO is not enabled for this workspace', + }); + }); + }); + + describe('handleAcs', () => { + const testSamlResponse = 'base64-encoded-saml-response'; + const testRelayStateId = 'test-relay-state-id'; + const testNameId = 'user@idp.example.com'; + const testEmail = 'user@example.com'; + const testRequestId = '_test-request-id-12345'; + + const mockSamlResponseData = { + nameId: testNameId, + email: testEmail, + name: 'Test User', + inResponseTo: testRequestId, + }; + + beforeEach(() => { + mockReq.params = { workspaceId: testWorkspaceId }; + mockReq.body = { + SAMLResponse: testSamlResponse, + RelayState: testRelayStateId, + }; + }); + + it('should process SAML response and redirect to frontend with tokens', async () => { + const workspace = createMockWorkspace(); + const user = createMockUser(); + + /** + * Setup test data + */ + const testReturnUrl = '/workspace/test'; + const expectedFrontendUrl = `${process.env.GARAGE_URL}${testReturnUrl}`; + + mockWorkspacesFactory.findById.mockResolvedValue(workspace); + mockUsersFactory.findBySamlIdentity.mockResolvedValue(user); + mockSamlService.validateAndParseResponse.mockResolvedValue(mockSamlResponseData); + + /** + * Setup samlStore to return valid state for tests + */ + samlStore.saveRelayState(testRelayStateId, { + returnUrl: testReturnUrl, + workspaceId: testWorkspaceId, + }); + samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); + + await controller.handleAcs(mockReq as Request, mockRes as Response); + + /** + * Verify workspace was fetched + */ + expect(mockWorkspacesFactory.findById).toHaveBeenCalledWith(testWorkspaceId); + + /** + * Verify SAML response was validated + */ + expect(mockSamlService.validateAndParseResponse).toHaveBeenCalledWith( + testSamlResponse, + testWorkspaceId, + expect.stringContaining(`/auth/sso/saml/${testWorkspaceId}/acs`), + testSamlConfig + ); + + /** + * Verify InResponseTo validation was performed + * (samlStore is singleton, validation happens internally) + */ + + /** + * Verify user lookup + */ + expect(mockUsersFactory.findBySamlIdentity).toHaveBeenCalledWith( + testWorkspaceId, + testNameId + ); + + /** + * Verify tokens were generated + */ + expect(user.generateTokensPair).toHaveBeenCalled(); + + /** + * Verify redirect to frontend with returnUrl from RelayState + * GARAGE_URL is set in beforeEach: 'https://garage.example.com' + */ + expect(mockRes.redirect).toHaveBeenCalledWith( + expect.stringContaining(expectedFrontendUrl) + ); + + const redirectUrl = new URL((mockRes.redirect as jest.Mock).mock.calls[0][0]); + expect(redirectUrl.searchParams.get('access_token')).toBe('test-access-token'); + expect(redirectUrl.searchParams.get('refresh_token')).toBe('test-refresh-token'); + }); + + it('should return 400 error when workspace is not found', async () => { + mockWorkspacesFactory.findById.mockResolvedValue(null); + + await controller.handleAcs(mockReq as Request, mockRes as Response); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'SSO is not enabled', + }); + }); + + it('should return 400 error when SSO is not enabled', async () => { + const workspace = createMockWorkspace({ sso: undefined }); + mockWorkspacesFactory.findById.mockResolvedValue(workspace); + + await controller.handleAcs(mockReq as Request, mockRes as Response); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'SSO is not enabled', + }); + }); + + it('should return 400 error when SAML validation fails', async () => { + const workspace = createMockWorkspace(); + mockWorkspacesFactory.findById.mockResolvedValue(workspace); + mockSamlService.validateAndParseResponse.mockRejectedValue( + new Error('Invalid signature') + ); + + await controller.handleAcs(mockReq as Request, mockRes as Response); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Invalid SAML response', + }); + expect(mockRes.redirect).not.toHaveBeenCalled(); + }); + + it('should return 400 error when InResponseTo validation fails', async () => { + const workspace = createMockWorkspace(); + mockWorkspacesFactory.findById.mockResolvedValue(workspace); + mockSamlService.validateAndParseResponse.mockResolvedValue(mockSamlResponseData); + + /** + * Don't save AuthnRequest, so validation will fail + */ + + await controller.handleAcs(mockReq as Request, mockRes as Response); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Invalid SAML response: InResponseTo validation failed', + }); + }); + + it('should create user with JIT provisioning when user not found', async () => { + const workspace = createMockWorkspace(); + const newUser = createMockUser({ email: testEmail }); + + mockWorkspacesFactory.findById.mockResolvedValue(workspace); + mockUsersFactory.findBySamlIdentity.mockResolvedValue(null); + mockUsersFactory.findByEmail.mockResolvedValue(null); + mockUsersFactory.create.mockResolvedValue(newUser); + mockSamlService.validateAndParseResponse.mockResolvedValue(mockSamlResponseData); + + /** + * Setup samlStore with valid state + */ + samlStore.saveRelayState(testRelayStateId, { + returnUrl: '/workspace/test', + workspaceId: testWorkspaceId, + }); + samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); + (workspace.getMemberInfo as jest.Mock).mockResolvedValue(null); + + await controller.handleAcs(mockReq as Request, mockRes as Response); + + /** + * Verify user was created + */ + expect(mockUsersFactory.create).toHaveBeenCalledWith(testEmail, undefined, undefined); + + /** + * Verify SAML identity was linked + */ + expect(newUser.linkSamlIdentity).toHaveBeenCalledWith( + testWorkspaceId, + testNameId, + testEmail + ); + + /** + * Verify user was added to workspace + */ + expect(workspace.addMember).toHaveBeenCalledWith(newUser._id.toString()); + expect(newUser.addWorkspace).toHaveBeenCalledWith(testWorkspaceId); + + expect(mockRes.redirect).toHaveBeenCalled(); + }); + + it('should link existing user when found by email', async () => { + const workspace = createMockWorkspace(); + const existingUser = createMockUser({ email: testEmail }); + + mockWorkspacesFactory.findById.mockResolvedValue(workspace); + mockUsersFactory.findBySamlIdentity.mockResolvedValue(null); + mockUsersFactory.findByEmail.mockResolvedValue(existingUser); + mockSamlService.validateAndParseResponse.mockResolvedValue(mockSamlResponseData); + + /** + * Setup samlStore with valid state + */ + samlStore.saveRelayState(testRelayStateId, { + returnUrl: '/workspace/test', + workspaceId: testWorkspaceId, + }); + samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); + (workspace.getMemberInfo as jest.Mock).mockResolvedValue(null); + + await controller.handleAcs(mockReq as Request, mockRes as Response); + + /** + * Verify user was not created + */ + expect(mockUsersFactory.create).not.toHaveBeenCalled(); + + /** + * Verify SAML identity was linked to existing user + */ + expect(existingUser.linkSamlIdentity).toHaveBeenCalledWith( + testWorkspaceId, + testNameId, + testEmail + ); + }); + + it('should confirm pending membership when user is pending', async () => { + const workspace = createMockWorkspace(); + const user = createMockUser(); + + mockWorkspacesFactory.findById.mockResolvedValue(workspace); + mockUsersFactory.findBySamlIdentity.mockResolvedValue(null); + mockUsersFactory.findByEmail.mockResolvedValue(user); + mockSamlService.validateAndParseResponse.mockResolvedValue(mockSamlResponseData); + + /** + * Setup samlStore with valid state + */ + samlStore.saveRelayState(testRelayStateId, { + returnUrl: '/workspace/test', + workspaceId: testWorkspaceId, + }); + samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); + (workspace.getMemberInfo as jest.Mock).mockResolvedValue({ + userEmail: testEmail, + }); + + /** + * Mock isPendingMember static method + */ + const isPendingMemberSpy = jest.spyOn(WorkspaceModel, 'isPendingMember').mockReturnValue(true); + + await controller.handleAcs(mockReq as Request, mockRes as Response); + + /** + * Restore mock after test + */ + isPendingMemberSpy.mockRestore(); + + /** + * Verify pending membership was confirmed + */ + expect(workspace.confirmMembership).toHaveBeenCalledWith(user); + expect(user.confirmMembership).toHaveBeenCalledWith(testWorkspaceId); + }); + + it('should use default returnUrl when RelayState is not found', async () => { + const workspace = createMockWorkspace(); + const user = createMockUser(); + + mockWorkspacesFactory.findById.mockResolvedValue(workspace); + mockUsersFactory.findBySamlIdentity.mockResolvedValue(user); + mockSamlService.validateAndParseResponse.mockResolvedValue(mockSamlResponseData); + + /** + * Setup samlStore with AuthnRequest but no RelayState + */ + samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); + + await controller.handleAcs(mockReq as Request, mockRes as Response); + + /** + * Verify redirect uses default returnUrl + */ + expect(mockRes.redirect).toHaveBeenCalledWith( + expect.stringContaining(`/workspace/${testWorkspaceId}`) + ); + }); + }); +}); + diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 00000000..7764034b --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["jest"], + "noEmit": true + }, + "include": [ + "../src/**/*", + "./**/*" + ], + "exclude": [ + "./integration/**/*" + ] +} + From 76232df0190e6120c2cb3d589c265bfa9dfc8d27 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 7 Jan 2026 21:49:47 +0300 Subject: [PATCH 13/33] Add SSO config support with admin-only GraphQL directive Introduces a new @definedOnlyForAdmins directive to restrict certain fields to workspace admins, returning null for non-admins. Adds SSO configuration types, inputs, and resolvers to the workspace schema, including the sso field and updateWorkspaceSso mutation, both protected for admin access. Updates schema wiring to register the new directive and its transformer. --- src/directives/definedOnlyForAdmins.ts | 101 +++++++++++++++ src/resolvers/workspace.js | 77 +++++++++++ src/schema.ts | 4 + src/typeDefs/workspace.ts | 169 +++++++++++++++++++++++++ 4 files changed, 351 insertions(+) create mode 100644 src/directives/definedOnlyForAdmins.ts diff --git a/src/directives/definedOnlyForAdmins.ts b/src/directives/definedOnlyForAdmins.ts new file mode 100644 index 00000000..279b94f6 --- /dev/null +++ b/src/directives/definedOnlyForAdmins.ts @@ -0,0 +1,101 @@ +import { defaultFieldResolver, GraphQLSchema } from 'graphql'; +import { mapSchema, MapperKind, getDirective } from '@graphql-tools/utils'; +import { ResolverContextWithUser, UnknownGraphQLResolverResult } from '../types/graphql'; +import { ForbiddenError, UserInputError } from 'apollo-server-express'; +import WorkspaceModel from '../models/workspace'; + +/** + * Check if user is admin of workspace + * @param context - resolver context + * @param workspaceId - workspace id to check + * @returns true if user is admin, false otherwise + */ +async function isUserAdminOfWorkspace(context: ResolverContextWithUser, workspaceId: string): Promise { + try { + const workspace = await context.factories.workspacesFactory.findById(workspaceId); + + if (!workspace) { + return false; + } + + const member = await workspace.getMemberInfo(context.user.id); + + if (!member || WorkspaceModel.isPendingMember(member)) { + return false; + } + + return member.isAdmin || false; + } catch { + return false; + } +} + +/** + * Defines directive for fields that are only defined for admins + * Returns null for non-admin users instead of throwing error + * + * Works with object fields where parent object has _id field (workspace id) + * + * Usage: + * type Workspace { + * sso: WorkspaceSsoConfig @definedOnlyForAdmins + * } + */ +export default function definedOnlyForAdminsDirective(directiveName = 'definedOnlyForAdmins') { + return { + definedOnlyForAdminsDirectiveTypeDefs: ` + """ + Field is only defined for admins. Returns null for non-admin users. + Works with object fields where parent object has _id field (workspace id). + """ + directive @${directiveName} on FIELD_DEFINITION + `, + definedOnlyForAdminsDirectiveTransformer: (schema: GraphQLSchema) => + mapSchema(schema, { + [MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => { + const definedOnlyForAdminsDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + + if (definedOnlyForAdminsDirective) { + const { + resolve = defaultFieldResolver, + } = fieldConfig; + + /** + * New field resolver that checks admin rights + * @param resolverArgs - default GraphQL resolver args + */ + fieldConfig.resolve = async (...resolverArgs): UnknownGraphQLResolverResult => { + const [parent, , context] = resolverArgs; + + /** + * Get workspace ID from parent object + * Parent should have _id field (workspace) + */ + if (!parent || !parent._id) { + return null; + } + + const workspaceId = parent._id.toString(); + + /** + * Check if user is admin + */ + const isAdmin = await isUserAdminOfWorkspace(context, workspaceId); + + if (!isAdmin) { + return null; + } + + /** + * Call original resolver + */ + return resolve(...resolverArgs); + }; + } + + return fieldConfig; + }, + }), + }; +} + diff --git a/src/resolvers/workspace.js b/src/resolvers/workspace.js index 8b46a5b1..1d5b09c5 100644 --- a/src/resolvers/workspace.js +++ b/src/resolvers/workspace.js @@ -329,6 +329,61 @@ module.exports = { return true; }, + /** + * Update workspace SSO configuration (admin only) + * Protected by @requireAdmin directive - admin check is done by directive + * @param {ResolverObj} _obj - object that contains the result returned from the resolver on the parent field + * @param {String} workspaceId - workspace ID + * @param {Object} config - SSO configuration + * @param {ContextFactories} factories - factories for working with models + * @return {Promise} + */ + async updateWorkspaceSso(_obj, { workspaceId, config }, { factories }) { + const workspace = await factories.workspacesFactory.findById(workspaceId); + + if (!workspace) { + throw new UserInputError('Workspace not found'); + } + + /** + * Validate configuration + */ + if (config.enabled && !config.saml) { + throw new UserInputError('SAML configuration is required when SSO is enabled'); + } + + /** + * Prepare update data + * If enabled=false, preserve existing SSO config and only update enabled flag + * If enabled=true, update full SSO configuration + */ + const updateData = { + ...workspace, + sso: config.enabled ? { + enabled: config.enabled, + enforced: config.enforced || false, + type: 'saml', + saml: { + idpEntityId: config.saml.idpEntityId, + ssoUrl: config.saml.ssoUrl, + x509Cert: config.saml.x509Cert, + nameIdFormat: config.saml.nameIdFormat, + attributeMapping: { + email: config.saml.attributeMapping.email, + name: config.saml.attributeMapping.name, + }, + }, + } : workspace.sso ? { + ...workspace.sso, + enabled: false, + } : undefined, + }; + + await workspace.updateWorkspace(updateData); + + return true; + }, + /** * Change workspace plan for default plan mutation implementation * @@ -493,6 +548,28 @@ module.exports = { return new PlanModel(plan); }, + + /** + * SSO configuration (admin only) + * Protected by @definedOnlyForAdmins directive - returns null for non-admin users + * @param {WorkspaceDBScheme} workspace - result from resolver above (parent workspace object) + * @param _args - empty list of args + * @param {UserInContext} context - resolver context + * @returns {Promise} + */ + async sso(workspace, _args, { factories }) { + /** + * Get workspace model to access SSO config + * Admin check is done by @definedOnlyForAdmins directive + */ + const workspaceModel = await factories.workspacesFactory.findById(workspace._id.toString()); + + if (!workspaceModel) { + return null; + } + + return workspaceModel.sso || null; + }, }, /** diff --git a/src/schema.ts b/src/schema.ts index 562e93c0..f2fe5008 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -9,6 +9,7 @@ import uploadImageDirective from './directives/uploadImageDirective'; import allowAnonDirective from './directives/allowAnon'; import requireAdminDirective from './directives/requireAdmin'; import requireUserInWorkspaceDirective from './directives/requireUserInWorkspace'; +import definedOnlyForAdminsDirective from './directives/definedOnlyForAdmins'; const { renameFromDirectiveTypeDefs, renameFromDirectiveTransformer } = renameFromDirective(); const { defaultValueDirectiveTypeDefs, defaultValueDirectiveTransformer } = defaultValueDirective(); @@ -17,6 +18,7 @@ const { uploadImageDirectiveTypeDefs, uploadImageDirectiveTransformer } = upload const { allowAnonDirectiveTypeDefs, allowAnonDirectiveTransformer } = allowAnonDirective(); const { requireAdminDirectiveTypeDefs, requireAdminDirectiveTransformer } = requireAdminDirective(); const { requireUserInWorkspaceDirectiveTypeDefs, requireUserInWorkspaceDirectiveTransformer } = requireUserInWorkspaceDirective(); +const { definedOnlyForAdminsDirectiveTypeDefs, definedOnlyForAdminsDirectiveTransformer } = definedOnlyForAdminsDirective(); let schema = makeExecutableSchema({ typeDefs: mergeTypeDefs([ @@ -27,6 +29,7 @@ let schema = makeExecutableSchema({ allowAnonDirectiveTypeDefs, requireAdminDirectiveTypeDefs, requireUserInWorkspaceDirectiveTypeDefs, + definedOnlyForAdminsDirectiveTypeDefs, ...typeDefs, ]), resolvers, @@ -39,5 +42,6 @@ schema = uploadImageDirectiveTransformer(schema); schema = requireAdminDirectiveTransformer(schema); schema = allowAnonDirectiveTransformer(schema); schema = requireUserInWorkspaceDirectiveTransformer(schema); +schema = definedOnlyForAdminsDirectiveTransformer(schema); export default schema; diff --git a/src/typeDefs/workspace.ts b/src/typeDefs/workspace.ts index cb9e3a6b..e367c46e 100644 --- a/src/typeDefs/workspace.ts +++ b/src/typeDefs/workspace.ts @@ -136,6 +136,167 @@ export default gql` """ ids: [ID!] = [] ): [Project!] + + """ + SSO configuration (admin only, returns null for non-admin users) + """ + sso: WorkspaceSsoConfig @definedOnlyForAdmins + } + + """ + SAML attribute mapping configuration + """ + type SamlAttributeMapping { + """ + Attribute name for email in SAML Assertion + Used to map the email attribute from the SAML response to the email attribute in the Hawk database + """ + email: String! + + """ + Attribute name for user name in SAML Assertion + Used to map the name attribute from the SAML response to the name attribute in the Hawk database + """ + name: String + } + + """ + SAML SSO configuration + """ + type SamlConfig { + """ + IdP Entity ID + Used to ensure that the SAML response is coming from the correct IdP + """ + idpEntityId: String! + + """ + SSO URL + Used to redirect user to the correct IdP + """ + ssoUrl: String! + + """ + X.509 certificate (masked for security) + Used to verify the signature of the SAML response + """ + x509Cert: String! + + """ + NameID format + Used to specify the format of the NameID in the SAML response + """ + nameIdFormat: String + + """ + Attribute mapping + Used to map the attributes from the SAML response to the attributes in the Hawk database + """ + attributeMapping: SamlAttributeMapping! + } + + """ + SSO configuration (admin only) + """ + type WorkspaceSsoConfig { + """ + Is SSO enabled + Used to enable or disable SSO for the workspace + """ + enabled: Boolean! + + """ + Is SSO enforced + Used to enforce SSO login for the workspace. If true, only SSO login is allowed. + """ + enforced: Boolean! + + """ + SSO provider type + Used to specify the type of the SSO provider for the workspace + """ + type: String! + + """ + SAML-specific configuration + Used to configure the SAML-specific settings for the workspace + """ + saml: SamlConfig! + } + + """ + SAML attribute mapping input + """ + input SamlAttributeMappingInput { + """ + Attribute name for email in SAML Assertion + Used to map the email attribute from the SAML response to the email attribute in the Hawk database + """ + email: String! + + """ + Attribute name for user name in SAML Assertion + Used to map the name attribute from the SAML response to the name attribute in the Hawk database + """ + name: String + } + + """ + SAML SSO configuration input + """ + input SamlConfigInput { + """ + IdP Entity ID + Used to ensure that the SAML response is coming from the correct IdP + """ + idpEntityId: String! + + """ + SSO URL for redirecting user to IdP + Used to redirect user to the correct IdP + """ + ssoUrl: String! + + """ + X.509 certificate for signature verification (PEM format) + Used to verify the signature of the SAML response + """ + x509Cert: String! + + """ + Desired NameID format + Used to specify the format of the NameID in the SAML response + """ + nameIdFormat: String + + """ + Attribute mapping configuration + Used to map the attributes from the SAML response to the attributes in the Hawk database + """ + attributeMapping: SamlAttributeMappingInput! + } + + """ + SSO configuration input + """ + input WorkspaceSsoConfigInput { + """ + Is SSO enabled + Used to enable or disable SSO for the workspace + """ + enabled: Boolean! + + """ + Is SSO enforced (only SSO login allowed) + Used to enforce SSO login for the workspace. If true, only SSO login is allowed. + """ + enforced: Boolean! + + """ + SAML-specific configuration + Used to configure the SAML-specific settings for the workspace + """ + saml: SamlConfigInput! } extend type Query { @@ -286,5 +447,13 @@ export default gql` """ workspaceId: ID! ): Boolean! + + """ + Update workspace SSO configuration (admin only) + """ + updateWorkspaceSso( + workspaceId: ID! + config: WorkspaceSsoConfigInput! + ): Boolean! @requireAdmin } `; From e9a6848de8fd73a46b780a9aebe8b35eac6162c3 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 8 Jan 2026 00:06:58 +0300 Subject: [PATCH 14/33] Add dynamic Node version and improve SAML SSO error handling Dockerfiles and GitHub Actions workflow now use a dynamic Node.js version via build args, reading from .nvmrc for consistency. SAML SSO controller adds workspace ID validation, improved error handling, and clearer error responses for SSO initiation and ACS callback. Also documents REDIS_URL in environment types. --- .../workflows/build-and-push-docker-image.yml | 12 +- docker/Dockerfile.dev | 5 +- docker/Dockerfile.prod | 5 +- src/sso/saml/controller.ts | 277 +++++++++++------- src/types/env.d.ts | 8 + 5 files changed, 194 insertions(+), 113 deletions(-) diff --git a/.github/workflows/build-and-push-docker-image.yml b/.github/workflows/build-and-push-docker-image.yml index c6acacfc..5a029e32 100644 --- a/.github/workflows/build-and-push-docker-image.yml +++ b/.github/workflows/build-and-push-docker-image.yml @@ -48,11 +48,19 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} + - name: Read Node.js version from .nvmrc + id: node_version + run: | + NODE_VERSION=$(cat api/.nvmrc | tr -d 'v') + echo "version=${NODE_VERSION}" >> $GITHUB_OUTPUT + - name: Build and push image uses: docker/build-push-action@v3 with: - context: . - file: docker/Dockerfile.prod + context: ./api + file: ./api/docker/Dockerfile.prod + build-args: | + NODE_VERSION=${{ steps.node_version.outputs.version }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} push: ${{ github.ref == 'refs/heads/stage' || github.ref == 'refs/heads/prod' || startsWith(github.ref, 'refs/tags/v') }} diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 4235bb06..a675c98c 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -1,4 +1,5 @@ -FROM node:22-alpine as builder +ARG NODE_VERSION=24.11.1 +FROM node:${NODE_VERSION}-alpine as builder WORKDIR /usr/src/app RUN apk add --no-cache git gcc g++ python3 make musl-dev @@ -7,7 +8,7 @@ COPY package.json yarn.lock ./ RUN yarn install -FROM node:22-alpine +FROM node:${NODE_VERSION}-alpine WORKDIR /usr/src/app diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod index 71fa971c..d922fe7d 100644 --- a/docker/Dockerfile.prod +++ b/docker/Dockerfile.prod @@ -1,4 +1,5 @@ -FROM node:22-alpine as builder +ARG NODE_VERSION=24.11.1 +FROM node:${NODE_VERSION}-alpine as builder WORKDIR /usr/src/app RUN apk add --no-cache git gcc g++ python3 make musl-dev @@ -11,7 +12,7 @@ COPY . . RUN yarn build -FROM node:22-alpine +FROM node:${NODE_VERSION}-alpine WORKDIR /usr/src/app diff --git a/src/sso/saml/controller.ts b/src/sso/saml/controller.ts index 76c8ef20..f092c932 100644 --- a/src/sso/saml/controller.ts +++ b/src/sso/saml/controller.ts @@ -1,5 +1,6 @@ import express from 'express'; import { v4 as uuid } from 'uuid'; +import { ObjectId } from 'mongodb'; import SamlService from './service'; import samlStore from './store'; import { ContextFactories } from '../../types/graphql'; @@ -26,6 +27,16 @@ export default class SamlController { this.factories = factories; } + /** + * Validate workspace ID format + * + * @param workspaceId - workspace ID to validate + * @returns true if valid, false otherwise + */ + private isValidWorkspaceId(workspaceId: string): boolean { + return ObjectId.isValid(workspaceId); + } + /** * Compose Assertion Consumer Service URL for workspace * @@ -41,147 +52,199 @@ export default class SamlController { * Initiate SSO login (GET /auth/sso/saml/:workspaceId) */ public async initiateLogin(req: express.Request, res: express.Response): Promise { - const { workspaceId } = req.params; - const returnUrl = (req.query.returnUrl as string) || `/workspace/${workspaceId}`; + try { + const { workspaceId } = req.params; + const returnUrl = (req.query.returnUrl as string) || `/workspace/${workspaceId}`; - /** - * 1. Check if workspace has SSO enabled - */ - const workspace = await this.factories.workspacesFactory.findById(workspaceId); + /** + * Validate workspace ID format + */ + if (!this.isValidWorkspaceId(workspaceId)) { + res.status(400).json({ error: 'Invalid workspace ID' }); + return; + } - if (!workspace || !workspace.sso?.enabled) { - res.status(400).json({ error: 'SSO is not enabled for this workspace' }); - return; - } + /** + * 1. Check if workspace has SSO enabled + */ + const workspace = await this.factories.workspacesFactory.findById(workspaceId); - /** - * 2. Compose Assertion Consumer Service URL - */ - const acsUrl = this.getAcsUrl(workspaceId); - const relayStateId = uuid(); + if (!workspace || !workspace.sso?.enabled) { + res.status(400).json({ error: 'SSO is not enabled for this workspace' }); + return; + } - /** - * 3. Save RelayState to temporary storage - */ - samlStore.saveRelayState(relayStateId, { returnUrl, workspaceId }); + /** + * 2. Compose Assertion Consumer Service URL + */ + const acsUrl = this.getAcsUrl(workspaceId); + const relayStateId = uuid(); - /** - * 4. Generate AuthnRequest - */ - const { requestId, encodedRequest } = await this.samlService.generateAuthnRequest( - workspaceId, - acsUrl, - relayStateId, - workspace.sso.saml - ); + /** + * 3. Save RelayState to temporary storage + */ + samlStore.saveRelayState(relayStateId, { returnUrl, workspaceId }); - /** - * 5. Save AuthnRequest ID for InResponseTo validation - */ - samlStore.saveAuthnRequest(requestId, workspaceId); + /** + * 4. Generate AuthnRequest + */ + const { requestId, encodedRequest } = await this.samlService.generateAuthnRequest( + workspaceId, + acsUrl, + relayStateId, + workspace.sso.saml + ); - /** - * 6. Redirect to IdP - */ - const redirectUrl = new URL(workspace.sso.saml.ssoUrl); - redirectUrl.searchParams.set('SAMLRequest', encodedRequest); - redirectUrl.searchParams.set('RelayState', relayStateId); + /** + * 5. Save AuthnRequest ID for InResponseTo validation + */ + samlStore.saveAuthnRequest(requestId, workspaceId); + + /** + * 6. Redirect to IdP + */ + const redirectUrl = new URL(workspace.sso.saml.ssoUrl); + redirectUrl.searchParams.set('SAMLRequest', encodedRequest); + redirectUrl.searchParams.set('RelayState', relayStateId); - res.redirect(redirectUrl.toString()); + res.redirect(redirectUrl.toString()); + } catch (error) { + console.error('SSO initiation error:', { + workspaceId: req.params.workspaceId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + res.status(500).json({ error: 'Failed to initiate SSO login' }); + } } /** * Handle ACS callback (POST /auth/sso/saml/:workspaceId/acs) */ public async handleAcs(req: express.Request, res: express.Response): Promise { - const { workspaceId } = req.params; - const samlResponse = req.body.SAMLResponse as string; - const relayStateId = req.body.RelayState as string; - - /** - * 1. Get workspace SSO configuration and check if SSO is enabled - */ - const workspace = await this.factories.workspacesFactory.findById(workspaceId); - - if (!workspace || !workspace.sso?.enabled) { - res.status(400).json({ error: 'SSO is not enabled' }); - return; - } + try { + const { workspaceId } = req.params; + const samlResponse = req.body.SAMLResponse as string; + const relayStateId = req.body.RelayState as string; - /** - * 2. Validate and parse SAML Response - */ - const acsUrl = this.getAcsUrl(workspaceId); + /** + * Validate workspace ID format + */ + if (!this.isValidWorkspaceId(workspaceId)) { + res.status(400).json({ error: 'Invalid workspace ID' }); + return; + } - let samlData: SamlResponseData; + /** + * Validate required SAML response + */ + if (!samlResponse) { + res.status(400).json({ error: 'SAML response is required' }); + return; + } - try { /** - * Validate and parse SAML Response - * Note: InResponseTo validation is done separately after parsing + * 1. Get workspace SSO configuration and check if SSO is enabled */ - samlData = await this.samlService.validateAndParseResponse( - samlResponse, - workspaceId, - acsUrl, - workspace.sso.saml - ); + const workspace = await this.factories.workspacesFactory.findById(workspaceId); + + if (!workspace || !workspace.sso?.enabled) { + res.status(400).json({ error: 'SSO is not enabled for this workspace' }); + return; + } /** - * Validate InResponseTo against stored AuthnRequest + * 2. Validate and parse SAML Response */ - if (samlData.inResponseTo) { - const isValidRequest = samlStore.validateAndConsumeAuthnRequest( - samlData.inResponseTo, - workspaceId + const acsUrl = this.getAcsUrl(workspaceId); + + let samlData: SamlResponseData; + + try { + /** + * Validate and parse SAML Response + * Note: InResponseTo validation is done separately after parsing + */ + samlData = await this.samlService.validateAndParseResponse( + samlResponse, + workspaceId, + acsUrl, + workspace.sso.saml ); - if (!isValidRequest) { - res.status(400).json({ error: 'Invalid SAML response: InResponseTo validation failed' }); - return; + /** + * Validate InResponseTo against stored AuthnRequest + */ + if (samlData.inResponseTo) { + const isValidRequest = samlStore.validateAndConsumeAuthnRequest( + samlData.inResponseTo, + workspaceId + ); + + if (!isValidRequest) { + res.status(400).json({ error: 'Invalid SAML response: InResponseTo validation failed' }); + return; + } } + } catch (error) { + console.error('SAML validation error:', { + workspaceId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + res.status(400).json({ error: 'Invalid SAML response' }); + return; } - } catch (error) { - console.error('SAML validation error:', { - workspaceId, - error: error instanceof Error ? error.message : 'Unknown error', - }); - res.status(400).json({ error: 'Invalid SAML response' }); - return; - } - /** - * 3. Find or create user - */ - let user = await this.factories.usersFactory.findBySamlIdentity(workspaceId, samlData.nameId); + /** + * 3. Find or create user + */ + let user = await this.factories.usersFactory.findBySamlIdentity(workspaceId, samlData.nameId); + + if (!user) { + /** + * JIT provisioning or invite-only policy + */ + user = await this.handleUserProvisioning(workspaceId, samlData, workspace); + } - if (!user) { /** - * JIT provisioning or invite-only policy + * 4. Get RelayState for return URL (before consuming) + * Note: RelayState is consumed after first use, so we need to get it before validation */ - user = await this.handleUserProvisioning(workspaceId, samlData, workspace); - } + const relayState = samlStore.getRelayState(relayStateId); + const finalReturnUrl = relayState?.returnUrl || `/workspace/${workspaceId}`; - /** - * 4. Get RelayState for return URL (before consuming) - * Note: RelayState is consumed after first use, so we need to get it before validation - */ - const relayState = samlStore.getRelayState(relayStateId); - const finalReturnUrl = relayState?.returnUrl || `/workspace/${workspaceId}`; + /** + * 5. Create Hawk session + */ + const tokens = await user.generateTokensPair(); - /** - * 5. Create Hawk session - */ - const tokens = await user.generateTokensPair(); + /** + * 6. Redirect to Garage with tokens + */ + const frontendUrl = new URL(finalReturnUrl, process.env.GARAGE_URL || 'http://localhost:3000'); + frontendUrl.searchParams.set('access_token', tokens.accessToken); + frontendUrl.searchParams.set('refresh_token', tokens.refreshToken); - /** - * 6. Redirect to Garage with tokens - */ - const frontendUrl = new URL(finalReturnUrl, process.env.GARAGE_URL || 'http://localhost:3000'); - frontendUrl.searchParams.set('access_token', tokens.accessToken); - frontendUrl.searchParams.set('refresh_token', tokens.refreshToken); + res.redirect(frontendUrl.toString()); + } catch (error) { + /** + * Handle specific error types + */ + if (error instanceof Error && error.message.includes('SAML')) { + console.error('SAML processing error:', { + workspaceId: req.params.workspaceId, + error: error.message, + }); + res.status(400).json({ error: 'Invalid SAML response' }); + return; + } - res.redirect(frontendUrl.toString()); + console.error('ACS callback error:', { + workspaceId: req.params.workspaceId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + res.status(500).json({ error: 'Failed to process SSO callback' }); + } } /** diff --git a/src/types/env.d.ts b/src/types/env.d.ts index f75bbeed..cd341491 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -38,5 +38,13 @@ declare namespace NodeJS { * @example "urn:hawk:tracker:saml" */ SSO_SP_ENTITY_ID: string; + + /** + * Redis connection URL + * Used for caching and TimeSeries data + * + * @example "redis://redis:6379" (Docker) or "redis://localhost:6379" (local) + */ + REDIS_URL?: string; } } From 0ea60b7cfb88508dc3f6b75469dbc21270b11edb Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 8 Jan 2026 00:08:51 +0300 Subject: [PATCH 15/33] Update build-and-push-docker-image.yml --- .github/workflows/build-and-push-docker-image.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-push-docker-image.yml b/.github/workflows/build-and-push-docker-image.yml index 5a029e32..3b023a97 100644 --- a/.github/workflows/build-and-push-docker-image.yml +++ b/.github/workflows/build-and-push-docker-image.yml @@ -57,8 +57,8 @@ jobs: - name: Build and push image uses: docker/build-push-action@v3 with: - context: ./api - file: ./api/docker/Dockerfile.prod + context: api + file: api/docker/Dockerfile.prod build-args: | NODE_VERSION=${{ steps.node_version.outputs.version }} tags: ${{ steps.meta.outputs.tags }} From 1e9695b460512445c7dbf7cd996c8dc86a2f6743 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 8 Jan 2026 00:10:35 +0300 Subject: [PATCH 16/33] Update build-and-push-docker-image.yml --- .github/workflows/build-and-push-docker-image.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-push-docker-image.yml b/.github/workflows/build-and-push-docker-image.yml index 3b023a97..cfbb2395 100644 --- a/.github/workflows/build-and-push-docker-image.yml +++ b/.github/workflows/build-and-push-docker-image.yml @@ -51,14 +51,14 @@ jobs: - name: Read Node.js version from .nvmrc id: node_version run: | - NODE_VERSION=$(cat api/.nvmrc | tr -d 'v') + NODE_VERSION=$(cat .nvmrc | tr -d 'v') echo "version=${NODE_VERSION}" >> $GITHUB_OUTPUT - name: Build and push image uses: docker/build-push-action@v3 with: - context: api - file: api/docker/Dockerfile.prod + context: . + file: docker/Dockerfile.prod build-args: | NODE_VERSION=${{ steps.node_version.outputs.version }} tags: ${{ steps.meta.outputs.tags }} From 6b3d58cf71639df69f154a6069613b46d1f6ca40 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 8 Jan 2026 00:13:07 +0300 Subject: [PATCH 17/33] Update mongodb.ts --- src/metrics/mongodb.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/metrics/mongodb.ts b/src/metrics/mongodb.ts index dcdab08a..44fd608c 100644 --- a/src/metrics/mongodb.ts +++ b/src/metrics/mongodb.ts @@ -262,12 +262,10 @@ function logCommandFailed(event: any): void { export function setupMongoMetrics(client: MongoClient): void { /** * Skip setup in test environment - * Check NODE_ENV or if running under Jest */ if ( process.env.NODE_ENV === 'test' || - process.env.NODE_ENV === 'e2e' || - typeof jest !== 'undefined' + process.env.NODE_ENV === 'e2e' ) { return; } From 6cacb3a63126c7761add44c8060b903df6152593 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 8 Jan 2026 00:15:12 +0300 Subject: [PATCH 18/33] Update controller.test.ts --- test/sso/saml/controller.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/sso/saml/controller.test.ts b/test/sso/saml/controller.test.ts index 73f7945f..8e756b1a 100644 --- a/test/sso/saml/controller.test.ts +++ b/test/sso/saml/controller.test.ts @@ -440,7 +440,7 @@ describe('SamlController', () => { expect(mockRes.status).toHaveBeenCalledWith(400); expect(mockRes.json).toHaveBeenCalledWith({ - error: 'SSO is not enabled', + error: 'SSO is not enabled for this workspace', }); }); @@ -452,7 +452,7 @@ describe('SamlController', () => { expect(mockRes.status).toHaveBeenCalledWith(400); expect(mockRes.json).toHaveBeenCalledWith({ - error: 'SSO is not enabled', + error: 'SSO is not enabled for this workspace', }); }); From 19d03f664e3fe79b638c004894a76429370fb0ef Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 8 Jan 2026 01:01:49 +0300 Subject: [PATCH 19/33] Add public SSO workspace info query Introduces the ssoWorkspace query to fetch public workspace info (id, name, image) for SSO login pages. Updates GraphQL type definitions with WorkspacePreview type and exposes ssoWorkspace query for unauthenticated access. --- src/resolvers/workspace.js | 29 +++++++++++++++++++++++++++++ src/typeDefs/workspace.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/resolvers/workspace.js b/src/resolvers/workspace.js index 1d5b09c5..3b821489 100644 --- a/src/resolvers/workspace.js +++ b/src/resolvers/workspace.js @@ -33,6 +33,35 @@ module.exports = { return factories.workspacesFactory.findManyByIds(await authenticatedUser.getWorkspacesIds(ids)); }, + + /** + * Get workspace public info by ID for SSO login page + * Returns only id, name, image if SSO is enabled for the workspace + * Available without authentication (@allowAnon) + * @param {ResolverObj} _obj - object that contains the result returned from the resolver on the parent field + * @param {String} id - workspace ID + * @param {ContextFactories} factories - factories for working with models + * @return {Object|null} Workspace public info or null if workspace not found or SSO not enabled + */ + async ssoWorkspace(_obj, { id }, { factories }) { + const workspace = await factories.workspacesFactory.findById(id); + + /** + * Check if workspace exists and has SSO enabled + */ + if (!workspace || !workspace.sso?.enabled) { + // return null; + } + + /** + * Return only public fields: id, name, image + */ + return { + _id: workspace._id, + name: workspace.name, + image: workspace.image || null, + }; + }, }, Mutation: { /** diff --git a/src/typeDefs/workspace.ts b/src/typeDefs/workspace.ts index e367c46e..a40ad0f7 100644 --- a/src/typeDefs/workspace.ts +++ b/src/typeDefs/workspace.ts @@ -299,12 +299,41 @@ export default gql` saml: SamlConfigInput! } + """ + Workspace preview with basic public info + Contains only basic fields: id, name, image + Used for public-facing features like SSO login page + """ + type WorkspacePreview { + """ + Workspace ID + """ + id: ID! @renameFrom(name: "_id") + + """ + Workspace name + """ + name: String! + + """ + Workspace image/logo URL + """ + image: String + } + extend type Query { """ Returns workspace(s) info If ids = [] returns all user's workspaces """ workspaces("Workspace(s) id(s)" ids: [ID] = []): [Workspace] + + """ + Get workspace public info by ID for SSO login page + Returns only id, name, image if SSO is enabled for the workspace + Available without authentication + """ + ssoWorkspace("Workspace ID" id: ID!): WorkspacePreview @allowAnon } extend type Mutation { From 65c22e3f9ecca675b10f8166960904aef6491e6d Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 8 Jan 2026 01:02:04 +0300 Subject: [PATCH 20/33] Update workspace.js --- src/resolvers/workspace.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resolvers/workspace.js b/src/resolvers/workspace.js index 3b821489..4ea0c29e 100644 --- a/src/resolvers/workspace.js +++ b/src/resolvers/workspace.js @@ -50,7 +50,7 @@ module.exports = { * Check if workspace exists and has SSO enabled */ if (!workspace || !workspace.sso?.enabled) { - // return null; + return null; } /** From 154f59ee8c91bc5b3451a03a7e5c35ffabaa4ba4 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Mon, 12 Jan 2026 16:13:36 +0300 Subject: [PATCH 21/33] Enforce SSO login and refactor SSO config update Added enforcement of SSO login for users in workspaces with enforced SSO. Refactored SSO configuration update logic by introducing setSsoConfig method in WorkspaceModel and updating resolver to use it, ensuring only SSO config is modified. --- src/models/workspace.ts | 19 ++++++++++++++++ src/resolvers/user.ts | 15 +++++++++++++ src/resolvers/workspace.js | 45 +++++++++++++++++++------------------- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/src/models/workspace.ts b/src/models/workspace.ts index cfb99344..6b0cde0e 100644 --- a/src/models/workspace.ts +++ b/src/models/workspace.ts @@ -420,6 +420,25 @@ export default class WorkspaceModel extends AbstractModel imp ); } + /** + * Update SSO configuration + * @param ssoConfig - SSO configuration to set (or undefined to remove) + */ + public async setSsoConfig(ssoConfig: WorkspaceDBScheme['sso'] | undefined): Promise { + this.sso = ssoConfig; + + await this.collection.updateOne( + { + _id: new ObjectId(this._id), + }, + { + $set: { + sso: this.sso, + }, + } + ); + } + /** * Due date of the current workspace tariff plan */ diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index af62dc80..8f606b28 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -98,6 +98,21 @@ export default { throw new AuthenticationError('Wrong email or password'); } + /** + * Check if there is a workspace with enforced SSO + * If user is a member of any workspace with enforced SSO, they must use SSO login + */ + const workspacesIds = await user.getWorkspacesIds([]); + const workspaces = await factories.workspacesFactory.findManyByIds(workspacesIds); + + const enforcedWorkspace = workspaces.find(w => w.sso?.enabled && w.sso?.enforced); + + if (enforcedWorkspace) { + throw new AuthenticationError( + 'This workspace requires SSO login. Please use SSO to sign in.' + ); + } + return user.generateTokensPair(); }, diff --git a/src/resolvers/workspace.js b/src/resolvers/workspace.js index 4ea0c29e..4565b640 100644 --- a/src/resolvers/workspace.js +++ b/src/resolvers/workspace.js @@ -382,33 +382,34 @@ module.exports = { } /** - * Prepare update data + * Prepare SSO configuration * If enabled=false, preserve existing SSO config and only update enabled flag * If enabled=true, update full SSO configuration */ - const updateData = { - ...workspace, - sso: config.enabled ? { - enabled: config.enabled, - enforced: config.enforced || false, - type: 'saml', - saml: { - idpEntityId: config.saml.idpEntityId, - ssoUrl: config.saml.ssoUrl, - x509Cert: config.saml.x509Cert, - nameIdFormat: config.saml.nameIdFormat, - attributeMapping: { - email: config.saml.attributeMapping.email, - name: config.saml.attributeMapping.name, - }, + const ssoConfig = config.enabled ? { + enabled: config.enabled, + enforced: config.enforced || false, + type: 'saml', + saml: { + idpEntityId: config.saml.idpEntityId, + ssoUrl: config.saml.ssoUrl, + x509Cert: config.saml.x509Cert, + nameIdFormat: config.saml.nameIdFormat, + attributeMapping: { + email: config.saml.attributeMapping.email, + name: config.saml.attributeMapping.name, }, - } : workspace.sso ? { - ...workspace.sso, - enabled: false, - } : undefined, - }; + }, + } : workspace.sso ? { + ...workspace.sso, + enabled: false, + } : undefined; - await workspace.updateWorkspace(updateData); + /** + * Update SSO configuration using model method + * This method handles the update correctly without touching other fields + */ + await workspace.setSsoConfig(ssoConfig); return true; }, From c384e7133698dde1906ad0e4873671ad4895b8d3 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Mon, 12 Jan 2026 18:19:31 +0300 Subject: [PATCH 22/33] add logs to the sso controller --- src/sso/saml/controller.ts | 237 +++++++++++++++++++++++++++++-------- 1 file changed, 190 insertions(+), 47 deletions(-) diff --git a/src/sso/saml/controller.ts b/src/sso/saml/controller.ts index f092c932..51f3d9fe 100644 --- a/src/sso/saml/controller.ts +++ b/src/sso/saml/controller.ts @@ -7,6 +7,7 @@ import { ContextFactories } from '../../types/graphql'; import { SamlResponseData } from '../types'; import WorkspaceModel from '../../models/workspace'; import UserModel from '../../models/user'; +import { sgr, Effect } from '../../utils/ansi'; /** * Controller for SAML SSO endpoints @@ -27,6 +28,26 @@ export default class SamlController { this.factories = factories; } + /** + * Log message with SSO prefix + * + * @param level - log level ('log', 'warn', 'error', 'info', 'success') + * @param args - arguments to log + */ + private log(level: 'log' | 'warn' | 'error' | 'info' | 'success', ...args: unknown[]): void { + const colors = { + log: Effect.ForegroundGreen, + warn: Effect.ForegroundYellow, + error: Effect.ForegroundRed, + info: Effect.ForegroundBlue, + success: [Effect.ForegroundGreen, Effect.Bold], + }; + + const logger = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; + + logger(sgr('[SSO]', colors[level]), ...args); + } + /** * Validate workspace ID format * @@ -52,14 +73,16 @@ export default class SamlController { * Initiate SSO login (GET /auth/sso/saml/:workspaceId) */ public async initiateLogin(req: express.Request, res: express.Response): Promise { + const { workspaceId } = req.params; + try { - const { workspaceId } = req.params; const returnUrl = (req.query.returnUrl as string) || `/workspace/${workspaceId}`; /** * Validate workspace ID format */ if (!this.isValidWorkspaceId(workspaceId)) { + this.log('warn', 'Invalid workspace ID format:', sgr(workspaceId, Effect.ForegroundRed)); res.status(400).json({ error: 'Invalid workspace ID' }); return; } @@ -70,6 +93,7 @@ export default class SamlController { const workspace = await this.factories.workspacesFactory.findById(workspaceId); if (!workspace || !workspace.sso?.enabled) { + this.log('warn', 'SSO not enabled for workspace:', sgr(workspaceId, Effect.ForegroundCyan)); res.status(400).json({ error: 'SSO is not enabled for this workspace' }); return; } @@ -107,12 +131,23 @@ export default class SamlController { redirectUrl.searchParams.set('SAMLRequest', encodedRequest); redirectUrl.searchParams.set('RelayState', relayStateId); + this.log( + 'log', + 'Initiating SSO login for workspace:', + sgr(workspaceId, [Effect.ForegroundCyan, Effect.Bold]), + '| Request ID:', + sgr(requestId.slice(0, 8), Effect.ForegroundGray) + ); + res.redirect(redirectUrl.toString()); } catch (error) { - console.error('SSO initiation error:', { - workspaceId: req.params.workspaceId, - error: error instanceof Error ? error.message : 'Unknown error', - }); + this.log( + 'error', + 'SSO initiation error for workspace:', + sgr(workspaceId, Effect.ForegroundCyan), + '|', + sgr(error instanceof Error ? error.message : 'Unknown error', Effect.ForegroundRed) + ); res.status(500).json({ error: 'Failed to initiate SSO login' }); } } @@ -121,8 +156,9 @@ export default class SamlController { * Handle ACS callback (POST /auth/sso/saml/:workspaceId/acs) */ public async handleAcs(req: express.Request, res: express.Response): Promise { + const { workspaceId } = req.params; + try { - const { workspaceId } = req.params; const samlResponse = req.body.SAMLResponse as string; const relayStateId = req.body.RelayState as string; @@ -130,6 +166,7 @@ export default class SamlController { * Validate workspace ID format */ if (!this.isValidWorkspaceId(workspaceId)) { + this.log('warn', '[ACS] Invalid workspace ID format:', sgr(workspaceId, Effect.ForegroundRed)); res.status(400).json({ error: 'Invalid workspace ID' }); return; } @@ -138,6 +175,7 @@ export default class SamlController { * Validate required SAML response */ if (!samlResponse) { + this.log('warn', '[ACS] Missing SAML response for workspace:', sgr(workspaceId, Effect.ForegroundCyan)); res.status(400).json({ error: 'SAML response is required' }); return; } @@ -148,6 +186,7 @@ export default class SamlController { const workspace = await this.factories.workspacesFactory.findById(workspaceId); if (!workspace || !workspace.sso?.enabled) { + this.log('warn', '[ACS] SSO not enabled for workspace:', sgr(workspaceId, Effect.ForegroundCyan)); res.status(400).json({ error: 'SSO is not enabled for this workspace' }); return; } @@ -171,6 +210,14 @@ export default class SamlController { workspace.sso.saml ); + this.log( + 'log', + '[ACS] SAML response validated for workspace:', + sgr(workspaceId, Effect.ForegroundCyan), + '| User:', + sgr(samlData.email, [Effect.ForegroundMagenta, Effect.Bold]) + ); + /** * Validate InResponseTo against stored AuthnRequest */ @@ -181,15 +228,25 @@ export default class SamlController { ); if (!isValidRequest) { + this.log( + 'error', + '[ACS] InResponseTo validation failed for workspace:', + sgr(workspaceId, Effect.ForegroundCyan), + '| Request ID:', + sgr(samlData.inResponseTo.slice(0, 8), Effect.ForegroundGray) + ); res.status(400).json({ error: 'Invalid SAML response: InResponseTo validation failed' }); return; } } } catch (error) { - console.error('SAML validation error:', { - workspaceId, - error: error instanceof Error ? error.message : 'Unknown error', - }); + this.log( + 'error', + '[ACS] SAML validation error for workspace:', + sgr(workspaceId, Effect.ForegroundCyan), + '|', + sgr(error instanceof Error ? error.message : 'Unknown error', Effect.ForegroundRed) + ); res.status(400).json({ error: 'Invalid SAML response' }); return; } @@ -203,7 +260,22 @@ export default class SamlController { /** * JIT provisioning or invite-only policy */ + this.log( + 'info', + '[ACS] User not found, starting provisioning:', + sgr(samlData.email, Effect.ForegroundMagenta), + '| Workspace:', + sgr(workspaceId, Effect.ForegroundCyan) + ); user = await this.handleUserProvisioning(workspaceId, samlData, workspace); + } else { + this.log( + 'log', + '[ACS] Existing user found:', + sgr(samlData.email, Effect.ForegroundMagenta), + '| User ID:', + sgr(user._id.toString().slice(0, 8), Effect.ForegroundGray) + ); } /** @@ -225,24 +297,40 @@ export default class SamlController { frontendUrl.searchParams.set('access_token', tokens.accessToken); frontendUrl.searchParams.set('refresh_token', tokens.refreshToken); + this.log( + 'success', + '[ACS] ✓ SSO login successful:', + sgr(samlData.email, [Effect.ForegroundMagenta, Effect.Bold]), + '| Workspace:', + sgr(workspaceId, Effect.ForegroundCyan), + '| Redirecting to:', + sgr(finalReturnUrl, Effect.ForegroundGray) + ); + res.redirect(frontendUrl.toString()); } catch (error) { /** * Handle specific error types */ if (error instanceof Error && error.message.includes('SAML')) { - console.error('SAML processing error:', { - workspaceId: req.params.workspaceId, - error: error.message, - }); + this.log( + 'error', + '[ACS] SAML processing error for workspace:', + sgr(workspaceId, Effect.ForegroundCyan), + '|', + sgr(error.message, Effect.ForegroundRed) + ); res.status(400).json({ error: 'Invalid SAML response' }); return; } - console.error('ACS callback error:', { - workspaceId: req.params.workspaceId, - error: error instanceof Error ? error.message : 'Unknown error', - }); + this.log( + 'error', + '[ACS] ACS callback error for workspace:', + sgr(workspaceId, Effect.ForegroundCyan), + '|', + sgr(error instanceof Error ? error.message : 'Unknown error', Effect.ForegroundRed) + ); res.status(500).json({ error: 'Failed to process SSO callback' }); } } @@ -260,44 +348,99 @@ export default class SamlController { samlData: SamlResponseData, workspace: WorkspaceModel ): Promise { - /** - * Find user by email - */ - let user = await this.factories.usersFactory.findByEmail(samlData.email); - - if (!user) { + try { /** - * Create new user (JIT provisioning) - * Password is not set - only SSO login is allowed + * Find user by email */ - user = await this.factories.usersFactory.create(samlData.email, undefined, undefined); - } + let user = await this.factories.usersFactory.findByEmail(samlData.email); - /** - * Link SAML identity to user - */ - await user.linkSamlIdentity(workspaceId, samlData.nameId, samlData.email); - - /** - * Check if user is a member of the workspace - */ - const member = await workspace.getMemberInfo(user._id.toString()); + if (!user) { + /** + * Create new user (JIT provisioning) + * Password is not set - only SSO login is allowed + */ + this.log( + 'info', + '[Provisioning] Creating new user:', + sgr(samlData.email, [Effect.ForegroundMagenta, Effect.Bold]), + '| Workspace:', + sgr(workspaceId, Effect.ForegroundCyan) + ); + user = await this.factories.usersFactory.create(samlData.email, undefined, undefined); + } - if (!member) { /** - * Add user to workspace (JIT provisioning) + * Link SAML identity to user */ - await workspace.addMember(user._id.toString()); - await user.addWorkspace(workspaceId); - } else if (WorkspaceModel.isPendingMember(member)) { + this.log( + 'info', + '[Provisioning] Linking SAML identity for user:', + sgr(samlData.email, Effect.ForegroundMagenta), + '| NameID:', + sgr(samlData.nameId.slice(0, 16) + '...', Effect.ForegroundGray) + ); + await user.linkSamlIdentity(workspaceId, samlData.nameId, samlData.email); + /** - * Confirm pending membership + * Check if user is a member of the workspace */ - await workspace.confirmMembership(user); - await user.confirmMembership(workspaceId); - } + const member = await workspace.getMemberInfo(user._id.toString()); - return user; + if (!member) { + /** + * Add user to workspace (JIT provisioning) + */ + this.log( + 'log', + '[Provisioning] Adding user to workspace:', + sgr(samlData.email, Effect.ForegroundMagenta), + '| Workspace:', + sgr(workspaceId, Effect.ForegroundCyan) + ); + await workspace.addMember(user._id.toString()); + await user.addWorkspace(workspaceId); + } else if (WorkspaceModel.isPendingMember(member)) { + /** + * Confirm pending membership + */ + this.log( + 'log', + '[Provisioning] Confirming pending membership:', + sgr(samlData.email, Effect.ForegroundMagenta), + '| Workspace:', + sgr(workspaceId, Effect.ForegroundCyan) + ); + await workspace.confirmMembership(user); + await user.confirmMembership(workspaceId); + } else { + this.log( + 'log', + '[Provisioning] User already member of workspace:', + sgr(samlData.email, Effect.ForegroundMagenta) + ); + } + + this.log( + 'success', + '[Provisioning] ✓ User provisioning completed:', + sgr(samlData.email, [Effect.ForegroundMagenta, Effect.Bold]), + '| User ID:', + sgr(user._id.toString(), Effect.ForegroundGray) + ); + + return user; + } catch (error) { + this.log( + 'error', + '[Provisioning] Provisioning error for user:', + sgr(samlData.email, Effect.ForegroundMagenta), + '| Workspace:', + sgr(workspaceId, Effect.ForegroundCyan), + '|', + sgr(error instanceof Error ? error.message : 'Unknown error', Effect.ForegroundRed) + ); + throw error; + } } } From fbb0afc02f5b2f99232c2d69c406a2ce5f6eed51 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Mon, 12 Jan 2026 18:25:38 +0300 Subject: [PATCH 23/33] Shorten refresh token expiry for enforced SSO users Refresh token lifetime is now 2 days instead of 30 for users in workspaces with enforced SSO. This change applies to both standard and SAML SSO flows to improve security by requiring more frequent re-authentication. --- src/models/user.ts | 11 +++++++++-- src/resolvers/user.ts | 10 +++++++++- src/sso/saml/controller.ts | 3 ++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/models/user.ts b/src/models/user.ts index ff2703ab..468077c4 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -302,8 +302,15 @@ export default class UserModel extends AbstractModel> /** * Generates JWT + * + * @param isSsoEnforced - if true, use shorter token lifetime (2 days instead of 30) */ - public async generateTokensPair(): Promise { + public async generateTokensPair(isSsoEnforced = false): Promise { + /** + * Use shorter refresh token expiry for SSO users to enforce re-authentication + */ + const refreshTokenExpiry = isSsoEnforced ? '2d' : '30d'; + const accessToken = await jwt.sign( { userId: this._id, @@ -317,7 +324,7 @@ export default class UserModel extends AbstractModel> userId: this._id, }, process.env.JWT_SECRET_REFRESH_TOKEN as Secret, - { expiresIn: '30d' } + { expiresIn: refreshTokenExpiry } ); return { diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 8f606b28..303f1e2a 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -143,7 +143,15 @@ export default { throw new ApolloError('There is no users with that id'); } - return user.generateTokensPair(); + /** + * Check if user is member of any workspace with enforced SSO + * to use shorter token lifetime + */ + const workspacesIds = await user.getWorkspacesIds([]); + const workspaces = await factories.workspacesFactory.findManyByIds(workspacesIds); + const hasEnforcedSso = workspaces.some(w => w.sso?.enabled && w.sso?.enforced); + + return user.generateTokensPair(hasEnforcedSso); }, /** diff --git a/src/sso/saml/controller.ts b/src/sso/saml/controller.ts index 51f3d9fe..0b8cee8c 100644 --- a/src/sso/saml/controller.ts +++ b/src/sso/saml/controller.ts @@ -287,8 +287,9 @@ export default class SamlController { /** * 5. Create Hawk session + * Use shorter token lifetime for enforced SSO workspaces */ - const tokens = await user.generateTokensPair(); + const tokens = await user.generateTokensPair(workspace.sso?.enforced || false); /** * 6. Redirect to Garage with tokens From de004e3a6b7197534edd612acf5b93f0a91d6164 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Mon, 12 Jan 2026 18:28:16 +0300 Subject: [PATCH 24/33] Create sso.test.ts --- test/integration/cases/sso.test.ts | 150 +++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 test/integration/cases/sso.test.ts diff --git a/test/integration/cases/sso.test.ts b/test/integration/cases/sso.test.ts new file mode 100644 index 00000000..86002cc4 --- /dev/null +++ b/test/integration/cases/sso.test.ts @@ -0,0 +1,150 @@ +import { apiInstance } from '../utils'; +import { ObjectId } from 'mongodb'; + +/** + * Integration tests for SSO functionality + * + * These tests verify the full SSO flow without requiring a real IdP (Keycloak). + * Instead, we mock the SAML Response to test the ACS endpoint behavior. + */ +describe('SSO Integration Tests', () => { + const testWorkspaceId = new ObjectId().toString(); + const testUserId = new ObjectId().toString(); + + /** + * Test workspace SSO configuration + */ + const ssoConfig = { + enabled: true, + enforced: false, + saml: { + idpEntityId: 'https://idp.example.com/metadata', + ssoUrl: 'https://idp.example.com/sso', + x509Cert: '-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWgAwIBAgIJAKL0UG+mRKJzMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\n-----END CERTIFICATE-----', + nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + attributeMapping: { + email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name', + }, + }, + }; + + describe('SSO Login Initiation', () => { + test('Should redirect to IdP when SSO is enabled', async () => { + /** + * TODO: This test requires: + * 1. Creating a test workspace with SSO configuration in MongoDB + * 2. Calling GET /auth/sso/saml/:workspaceId + * 3. Verifying redirect to IdP SSO URL + * + * This will be implemented once the workspace creation via GraphQL is set up in tests + */ + expect(true).toBe(true); + }); + + test('Should return 400 if SSO is not enabled for workspace', async () => { + /** + * TODO: Test that attempting SSO login for workspace without SSO returns error + */ + expect(true).toBe(true); + }); + + test('Should return 400 if workspace does not exist', async () => { + const nonExistentWorkspaceId = new ObjectId().toString(); + + /** + * TODO: Test with non-existent workspace ID + */ + expect(true).toBe(true); + }); + }); + + describe('ACS (Assertion Consumer Service)', () => { + test('Should process valid SAML Response and create user session', async () => { + /** + * TODO: This test requires: + * 1. Creating a test workspace with SSO configuration + * 2. Mocking a valid SAML Response + * 3. POSTing to /auth/sso/saml/:workspaceId/acs + * 4. Verifying user is created (JIT provisioning) + * 5. Verifying session tokens are generated + * 6. Verifying redirect to frontend with tokens + */ + expect(true).toBe(true); + }); + + test('Should reject invalid SAML Response', async () => { + /** + * TODO: Test with invalid SAML Response (bad signature, expired, etc.) + */ + expect(true).toBe(true); + }); + + test('Should link SAML identity to existing user', async () => { + /** + * TODO: Test that if user with matching email exists, + * SAML identity is linked to that user + */ + expect(true).toBe(true); + }); + + test('Should respect RelayState and redirect correctly', async () => { + /** + * TODO: Test that RelayState is preserved and used for redirect + */ + expect(true).toBe(true); + }); + }); + + describe('SSO Enforcement', () => { + test('Should block email/password login when SSO is enforced', async () => { + /** + * TODO: This is already tested in user resolver tests, + * but we can add integration test here to verify end-to-end behavior + */ + expect(true).toBe(true); + }); + + test('Should allow SSO login even when enforced', async () => { + /** + * TODO: Verify SSO login works when enforcement is enabled + */ + expect(true).toBe(true); + }); + }); + + describe('Error Handling', () => { + test('Should handle missing SAML configuration gracefully', async () => { + /** + * TODO: Test error handling when workspace has SSO enabled + * but configuration is incomplete + */ + expect(true).toBe(true); + }); + + test('Should handle IdP errors gracefully', async () => { + /** + * TODO: Test handling of various IdP error responses + */ + expect(true).toBe(true); + }); + }); +}); + +/** + * NOTE: These are placeholder tests showing the structure. + * + * To fully implement these tests, we need: + * 1. GraphQL test utilities for creating workspaces with SSO config + * 2. SAML Response mocks (valid and invalid) + * 3. Helper functions for simulating SSO flow + * 4. MongoDB test data setup/teardown + * + * For now, the unit tests in test/sso/saml/ provide coverage for + * individual components (controller, service, store, utils). + * + * Future work: + * - Implement full integration tests with mocked SAML Responses + * - Add Keycloak container to docker-compose.test.yml + * - Create browser automation tests for full SSO flow + */ From 6e3d72256baa1c3c59ab328fb3ddb53a0d586310 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 14 Jan 2026 19:37:19 +0300 Subject: [PATCH 25/33] fixes for sso --- src/resolvers/user.ts | 14 ++++++++++++-- src/sso/saml/controller.ts | 22 ++++++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 303f1e2a..13c1b50f 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -108,9 +108,19 @@ export default { const enforcedWorkspace = workspaces.find(w => w.sso?.enabled && w.sso?.enforced); if (enforcedWorkspace) { - throw new AuthenticationError( - 'This workspace requires SSO login. Please use SSO to sign in.' + const error = new AuthenticationError( + 'SSO_REQUIRED' ); + + /** + * Add workspace info to extensions for frontend + */ + error.extensions = { + code: 'SSO_REQUIRED', + workspaceName: enforcedWorkspace.name, + workspaceId: enforcedWorkspace._id.toString(), + }; + throw error; } return user.generateTokensPair(); diff --git a/src/sso/saml/controller.ts b/src/sso/saml/controller.ts index 0b8cee8c..6d813a00 100644 --- a/src/sso/saml/controller.ts +++ b/src/sso/saml/controller.ts @@ -112,6 +112,19 @@ export default class SamlController { /** * 4. Generate AuthnRequest */ + const spEntityId = process.env.SSO_SP_ENTITY_ID || 'NOT_SET'; + + this.log( + 'info', + 'Generating SAML AuthnRequest:', + '| Workspace:', + sgr(workspaceId, Effect.ForegroundCyan), + '| SP Entity ID:', + sgr(spEntityId, [Effect.ForegroundMagenta, Effect.Bold]), + '| ACS URL:', + sgr(acsUrl, Effect.ForegroundGray) + ); + const { requestId, encodedRequest } = await this.samlService.generateAuthnRequest( workspaceId, acsUrl, @@ -292,11 +305,14 @@ export default class SamlController { const tokens = await user.generateTokensPair(workspace.sso?.enforced || false); /** - * 6. Redirect to Garage with tokens + * 6. Redirect to Garage SSO callback page with tokens + * The SSO callback page will save tokens to store and redirect to finalReturnUrl */ - const frontendUrl = new URL(finalReturnUrl, process.env.GARAGE_URL || 'http://localhost:3000'); + const callbackPath = `/login/sso/${workspaceId}`; + const frontendUrl = new URL(callbackPath, process.env.GARAGE_URL || 'http://localhost:3000'); frontendUrl.searchParams.set('access_token', tokens.accessToken); frontendUrl.searchParams.set('refresh_token', tokens.refreshToken); + frontendUrl.searchParams.set('returnUrl', finalReturnUrl); this.log( 'success', @@ -305,6 +321,8 @@ export default class SamlController { '| Workspace:', sgr(workspaceId, Effect.ForegroundCyan), '| Redirecting to:', + sgr(callbackPath, Effect.ForegroundGray), + '→', sgr(finalReturnUrl, Effect.ForegroundGray) ); From fc57f5839b7873dfda25d048a2171cb557897865 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 15 Jan 2026 19:50:49 +0300 Subject: [PATCH 26/33] integration tests --- docker-compose.test.yml | 38 +- docker/Dockerfile.dev | 2 +- package.json | 8 +- src/resolvers/user.ts | 11 +- test/integration/api.env | 9 +- test/integration/cases/sso.test.ts | 516 ++++++++++++++++++++++++---- test/integration/jestEnv.js | 8 + test/integration/utils.ts | 10 + test/integration/utils/keycloak.ts | 297 ++++++++++++++++ test/integration/utils/workspace.ts | 205 +++++++++++ yarn.lock | 8 +- 11 files changed, 1032 insertions(+), 80 deletions(-) create mode 100644 test/integration/utils/keycloak.ts create mode 100644 test/integration/utils/workspace.ts diff --git a/docker-compose.test.yml b/docker-compose.test.yml index ac51dae6..afd2b373 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -11,9 +11,11 @@ services: - ./:/usr/src/app - /usr/src/app/node_modules - ./test/integration/api.env:/usr/src/app/.env + - ../keycloak:/keycloak:ro depends_on: - mongodb - rabbitmq + - keycloak # - accounting stdin_open: true tty: true @@ -32,10 +34,20 @@ services: condition: service_healthy api: condition: service_started - command: dockerize -wait http://api:4000/.well-known/apollo/server-health -timeout 30s yarn jest --config=./test/integration/jest.config.js --runInBand test/integration + keycloak: + condition: service_healthy + environment: + - KEYCLOAK_URL=http://keycloak:8180 + entrypoint: ["/bin/bash", "-c"] + command: + - | + dockerize -wait http://api:4000/.well-known/apollo/server-health -timeout 30s -wait http://keycloak:8180/health/ready -timeout 60s && + /keycloak/setup.sh && + yarn jest --config=./test/integration/jest.config.js --runInBand test/integration volumes: - ./:/usr/src/app - /usr/src/app/node_modules + - ../keycloak:/keycloak:ro rabbitmq: image: rabbitmq:3-management @@ -52,6 +64,29 @@ services: timeout: 3s retries: 5 + keycloak: + image: quay.io/keycloak/keycloak:23.0 + environment: + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + - KC_HTTP_PORT=8180 + - KC_HOSTNAME_STRICT=false + - KC_HOSTNAME_STRICT_HTTPS=false + - KC_HTTP_ENABLED=true + - KC_HEALTH_ENABLED=true + ports: + - 8180:8180 + command: + - start-dev + volumes: + - keycloak-test-data:/opt/keycloak/data + - ../keycloak:/opt/keycloak/config + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180;echo -e 'GET /health/ready HTTP/1.1\r\nhost: http://localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"] + interval: 10s + timeout: 5s + retries: 10 + # accounting: # image: codexteamuser/codex-accounting:prod # env_file: @@ -61,3 +96,4 @@ services: volumes: mongodata-test: + keycloak-test-data: diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index a675c98c..e28c2646 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -12,7 +12,7 @@ FROM node:${NODE_VERSION}-alpine WORKDIR /usr/src/app -RUN apk add --no-cache openssl +RUN apk add --no-cache openssl bash curl ENV DOCKERIZE_VERSION v0.6.1 RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ diff --git a/package.json b/package.json index 1838361e..c190f8cd 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@shelf/jest-mongodb": "^6.0.2", "@swc/core": "^1.3.0", "@types/jest": "^26.0.8", + "@types/xml2js": "^0.4.14", "eslint": "^6.7.2", "eslint-config-codex": "1.2.4", "eslint-plugin-import": "^2.19.1", @@ -32,7 +33,8 @@ "redis-mock": "^0.56.3", "ts-jest": "^26.1.4", "ts-node": "^10.9.1", - "typescript": "^4.7.4" + "typescript": "^4.7.4", + "xml2js": "^0.6.2" }, "dependencies": { "@ai-sdk/openai": "^2.0.64", @@ -41,8 +43,9 @@ "@graphql-tools/schema": "^8.5.1", "@graphql-tools/utils": "^8.9.0", "@hawk.so/nodejs": "^3.1.1", - "@hawk.so/types": "^0.4.0", + "@hawk.so/types": "^0.4.2", "@n1ru4l/json-patch-plus": "^0.2.0", + "@node-saml/node-saml": "^5.0.1", "@types/amqp-connection-manager": "^2.0.4", "@types/debug": "^4.1.5", "@types/escape-html": "^1.0.0", @@ -57,7 +60,6 @@ "@types/uuid": "^8.3.4", "ai": "^5.0.89", "amqp-connection-manager": "^3.1.0", - "@node-saml/node-saml": "^5.0.1", "amqplib": "^0.5.5", "apollo-server-express": "^3.10.0", "argon2": "^0.28.7", diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 13c1b50f..7cadb46a 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -94,13 +94,15 @@ export default { ): Promise { const user = await factories.usersFactory.findByEmail(email); - if (!user || !(await user.comparePassword(password))) { + if (!user) { throw new AuthenticationError('Wrong email or password'); } /** * Check if there is a workspace with enforced SSO * If user is a member of any workspace with enforced SSO, they must use SSO login + * This check must happen BEFORE password validation to prevent password-based login + * even if the password is correct */ const workspacesIds = await user.getWorkspacesIds([]); const workspaces = await factories.workspacesFactory.findManyByIds(workspacesIds); @@ -123,6 +125,13 @@ export default { throw error; } + /** + * Only validate password if SSO is not enforced + */ + if (!(await user.comparePassword(password))) { + throw new AuthenticationError('Wrong email or password'); + } + return user.generateTokensPair(); }, diff --git a/test/integration/api.env b/test/integration/api.env index 1c068643..fe3384e4 100644 --- a/test/integration/api.env +++ b/test/integration/api.env @@ -72,13 +72,16 @@ GITHUB_CLIENT_ID=fakedata GITHUB_CLIENT_SECRET=fakedata ## Hawk API public url (used in OAuth to redirect to callback, should match OAuth app callback URL) -API_URL=http://127.0.0.1:4000 +API_URL=http://localhost:4000 ## Garage url -GARAGE_URL=http://127.0.0.1:8080 +GARAGE_URL=http://localhost:8080 ## Garage login url -GARAGE_LOGIN_URL=http://127.0.0.1:8080/login +GARAGE_LOGIN_URL=http://localhost:8080/login + +## SSO Service Provider Entity ID (must match Keycloak client ID) +SSO_SP_ENTITY_ID=urn:hawk:tracker:saml ## Upload dir UPLOAD_DIR=uploads diff --git a/test/integration/cases/sso.test.ts b/test/integration/cases/sso.test.ts index 86002cc4..14e6619b 100644 --- a/test/integration/cases/sso.test.ts +++ b/test/integration/cases/sso.test.ts @@ -1,96 +1,310 @@ -import { apiInstance } from '../utils'; +import { + apiInstance, + waitForKeycloak, + getKeycloakSamlConfig, + createMockSamlResponse, + testUsers, + createTestWorkspace, + createTestUser, + cleanupWorkspace, + cleanupUser, + getUserByEmail, +} from '../utils'; import { ObjectId } from 'mongodb'; /** * Integration tests for SSO functionality * - * These tests verify the full SSO flow without requiring a real IdP (Keycloak). - * Instead, we mock the SAML Response to test the ACS endpoint behavior. + * These tests verify the full SSO flow with Keycloak as IdP. + * For some tests, we use mock SAML Response for faster execution. */ describe('SSO Integration Tests', () => { - const testWorkspaceId = new ObjectId().toString(); - const testUserId = new ObjectId().toString(); + let testWorkspaceId: string; + let keycloakConfig: Awaited>; /** - * Test workspace SSO configuration + * Setup: Wait for Keycloak and get configuration */ - const ssoConfig = { - enabled: true, - enforced: false, - saml: { - idpEntityId: 'https://idp.example.com/metadata', - ssoUrl: 'https://idp.example.com/sso', - x509Cert: '-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWgAwIBAgIJAKL0UG+mRKJzMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\n-----END CERTIFICATE-----', - nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', - attributeMapping: { - email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', - name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name', + beforeAll(async () => { + /** + * Wait for Keycloak to be ready + */ + await waitForKeycloak(); + + /** + * Get Keycloak SAML configuration + */ + keycloakConfig = await getKeycloakSamlConfig(); + }, 60000); + + /** + * Create test workspace before each test + */ + beforeEach(async () => { + testWorkspaceId = await createTestWorkspace({ + name: 'Test SSO Workspace', + sso: { + enabled: true, + enforced: false, + type: 'saml', + saml: { + idpEntityId: keycloakConfig.idpEntityId, + ssoUrl: keycloakConfig.ssoUrl, + x509Cert: keycloakConfig.x509Cert, + nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + attributeMapping: { + email: 'email', + name: 'name', + }, + }, }, - }, - }; + }); + }); + + /** + * Cleanup after each test + */ + afterEach(async () => { + if (testWorkspaceId) { + await cleanupWorkspace(testWorkspaceId); + } + + /** + * Cleanup test users + */ + for (const user of Object.values(testUsers)) { + try { + await cleanupUser(user.email); + } catch (error) { + /** + * Ignore errors if user doesn't exist + */ + } + } + }); describe('SSO Login Initiation', () => { test('Should redirect to IdP when SSO is enabled', async () => { /** - * TODO: This test requires: - * 1. Creating a test workspace with SSO configuration in MongoDB - * 2. Calling GET /auth/sso/saml/:workspaceId - * 3. Verifying redirect to IdP SSO URL + * Test Plan: + * 1. Call GET /auth/sso/saml/:workspaceId with SSO-enabled workspace + * 2. Verify 302 redirect response + * 3. Verify redirect location contains IdP SSO URL + * 4. Verify redirect contains SAMLRequest and RelayState parameters * - * This will be implemented once the workspace creation via GraphQL is set up in tests + * Expected: User is redirected to Keycloak login page */ - expect(true).toBe(true); + + /** + * Step 1: Call SSO initiation endpoint + */ + const response = await apiInstance.get( + `/auth/sso/saml/${testWorkspaceId}`, + { + maxRedirects: 0, + validateStatus: () => true, + } + ); + + /** + * Step 2-4: Verify redirect to Keycloak with proper SAML parameters + */ + expect(response.status).toBe(302); + expect(response.headers.location).toBeDefined(); + expect(response.headers.location).toContain(keycloakConfig.ssoUrl); + expect(response.headers.location).toContain('SAMLRequest'); + expect(response.headers.location).toContain('RelayState'); }); test('Should return 400 if SSO is not enabled for workspace', async () => { /** - * TODO: Test that attempting SSO login for workspace without SSO returns error + * Test Plan: + * 1. Create a workspace without SSO configuration + * 2. Call GET /auth/sso/saml/:workspaceId for that workspace + * 3. Verify 400 error response with appropriate message + * + * Expected: API returns error indicating SSO is not enabled */ - expect(true).toBe(true); + + /** + * Step 1: Create workspace without SSO + */ + const workspaceWithoutSso = await createTestWorkspace({ + name: 'Workspace Without SSO', + }); + + try { + /** + * Step 2: Try to initiate SSO for workspace without SSO + */ + const response = await apiInstance.get( + `/auth/sso/saml/${workspaceWithoutSso}`, + { + validateStatus: () => true, + } + ); + + /** + * Step 3: Verify error response + */ + expect(response.status).toBe(400); + expect(response.data.error).toContain('SSO is not enabled'); + } finally { + await cleanupWorkspace(workspaceWithoutSso); + } }); test('Should return 400 if workspace does not exist', async () => { + /** + * Test Plan: + * 1. Generate a random workspace ID that doesn't exist in database + * 2. Call GET /auth/sso/saml/:workspaceId with non-existent ID + * 3. Verify 400 error response + * + * Expected: API returns error for non-existent workspace + */ + + /** + * Step 1: Generate non-existent workspace ID + */ const nonExistentWorkspaceId = new ObjectId().toString(); /** - * TODO: Test with non-existent workspace ID + * Step 2: Try to initiate SSO for non-existent workspace */ - expect(true).toBe(true); + const response = await apiInstance.get( + `/auth/sso/saml/${nonExistentWorkspaceId}`, + { + validateStatus: () => true, + } + ); + + expect(response.status).toBe(400); + expect(response.data.error).toBeDefined(); }); }); describe('ACS (Assertion Consumer Service)', () => { - test('Should process valid SAML Response and create user session', async () => { - /** - * TODO: This test requires: - * 1. Creating a test workspace with SSO configuration - * 2. Mocking a valid SAML Response - * 3. POSTing to /auth/sso/saml/:workspaceId/acs - * 4. Verifying user is created (JIT provisioning) - * 5. Verifying session tokens are generated - * 6. Verifying redirect to frontend with tokens - */ - expect(true).toBe(true); + + /** + * This test requires full E2E flow with browser automation + * + * 1. Initiate SSO login + * 2. Follow redirects to Keycloak + * 3. Submit login form + * 4. Receive SAML Response from Keycloak + * 5. Return to Hawk ACS endpoint + * 6. Verify user was created (JIT provisioning) + * 7. Verify user was logged in + * 8. Verify user was redirected to the correct return URL with tokens + */ + test.todo('Should process valid SAML Response and create user session', async () => { }); test('Should reject invalid SAML Response', async () => { /** - * TODO: Test with invalid SAML Response (bad signature, expired, etc.) + * Test Plan: + * 1. Create an invalid SAML Response (not properly encoded) + * 2. POST invalid SAMLResponse to ACS endpoint + * 3. Verify 400 error response + * + * Expected: API rejects invalid SAML Response */ - expect(true).toBe(true); + + /** + * Step 1-2: Send invalid SAML Response (not base64 encoded) + */ + const response = await apiInstance.post( + `/auth/sso/saml/${testWorkspaceId}/acs`, + new URLSearchParams({ + SAMLResponse: 'invalid-saml-response', + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + validateStatus: () => true, + } + ); + + expect(response.status).toBe(400); + expect(response.data.error).toBeDefined(); }); test('Should link SAML identity to existing user', async () => { /** - * TODO: Test that if user with matching email exists, - * SAML identity is linked to that user + * Test Plan: + * 1. Create a user in database first (pre-existing user) + * 2. Create mock SAML Response for that user's email + * 3. POST SAMLResponse to ACS endpoint + * 4. Verify SAML identity is linked to existing user (not creating new user) + * + * Expected: Existing user gets SAML identity linked */ - expect(true).toBe(true); + + const testUser = testUsers.alice; + + /** + * Step 1: Create user first (pre-existing user) + */ + await createTestUser({ + email: testUser.email, + name: testUser.firstName, + workspaces: [testWorkspaceId], + }); + + /** + * Step 2: Create mock SAML Response for existing user + */ + const samlResponse = createMockSamlResponse( + testUser.email, + testUser.email, + { + name: `${testUser.firstName} ${testUser.lastName}`, + acsUrl: `http://api:4000/auth/sso/saml/${testWorkspaceId}/acs`, + } + ); + + /** + * Step 3: POST SAML Response to ACS endpoint + */ + const response = await apiInstance.post( + `/auth/sso/saml/${testWorkspaceId}/acs`, + new URLSearchParams({ + SAMLResponse: samlResponse, + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + maxRedirects: 0, + validateStatus: () => true, + } + ); + + /** + * Step 4: Verify response + * + * Note: Mock SAML Response will fail validation (400) + * In a real scenario with valid SAML: + * - Existing user would have SAML identity linked + * - User would be logged in (302 redirect) + */ + expect([302, 400]).toContain(response.status); }); test('Should respect RelayState and redirect correctly', async () => { /** - * TODO: Test that RelayState is preserved and used for redirect + * Test Plan: + * 1. Call SSO initiation with returnUrl parameter + * 2. Extract RelayState from redirect + * 3. POST SAML Response with same RelayState + * 4. Verify final redirect includes original returnUrl + * + * Note: This test requires full E2E flow with browser automation + * Placeholder for now - to be implemented with puppeteer/playwright + * + * Expected: RelayState is preserved throughout SSO flow */ expect(true).toBe(true); }); @@ -99,32 +313,199 @@ describe('SSO Integration Tests', () => { describe('SSO Enforcement', () => { test('Should block email/password login when SSO is enforced', async () => { /** - * TODO: This is already tested in user resolver tests, - * but we can add integration test here to verify end-to-end behavior + * Test Plan: + * 1. Create workspace with SSO enabled and enforced + * 2. Create user in that workspace + * 3. Try to login via email/password through GraphQL mutation + * 4. Verify login is blocked with SSO_REQUIRED error + * + * Expected: Email/password login is blocked, user must use SSO */ - expect(true).toBe(true); + + /** + * Step 1: Create workspace with enforced SSO + */ + const enforcedWorkspace = await createTestWorkspace({ + name: 'Enforced SSO Workspace', + sso: { + enabled: true, + enforced: true, + type: 'saml', + saml: { + idpEntityId: keycloakConfig.idpEntityId, + ssoUrl: keycloakConfig.ssoUrl, + x509Cert: keycloakConfig.x509Cert, + nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + attributeMapping: { + email: 'email', + name: 'firstName', + }, + }, + }, + }); + + /** + * Step 2: Create user with password in enforced workspace + */ + const testUser = testUsers.bob; + + await createTestUser({ + email: testUser.email, + password: testUser.password, + name: testUser.firstName, + workspaces: [enforcedWorkspace], + }); + + /** + * Step 3: Try to login with email/password via GraphQL mutation + */ + const loginMutation = ` + mutation Login($email: String!, $password: String!) { + login(email: $email, password: $password) { + accessToken + refreshToken + } + } + `; + + const response = await apiInstance.post( + '/graphql', + { + query: loginMutation, + variables: { + email: testUser.email, + password: testUser.password, + }, + }, + { + validateStatus: () => true, + } + ); + + /** + * Step 4: Verify login is blocked with SSO error + */ + expect(response.data.errors).toBeDefined(); + expect(response.data.errors[0].message).toContain('SSO'); + + await cleanupWorkspace(enforcedWorkspace); }); test('Should allow SSO login even when enforced', async () => { /** - * TODO: Verify SSO login works when enforcement is enabled + * Test Plan: + * 1. Create workspace with SSO enabled and enforced + * 2. Call GET /auth/sso/saml/:workspaceId (SSO initiation) + * 3. Verify redirect to IdP works correctly + * + * Expected: SSO login works even when enforced (only email/password is blocked) */ - expect(true).toBe(true); + + /** + * Step 1: Create workspace with enforced SSO + */ + const enforcedWorkspace = await createTestWorkspace({ + name: 'Enforced SSO Workspace', + sso: { + enabled: true, + enforced: true, + type: 'saml', + saml: { + idpEntityId: keycloakConfig.idpEntityId, + ssoUrl: keycloakConfig.ssoUrl, + x509Cert: keycloakConfig.x509Cert, + nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + attributeMapping: { + email: 'email', + name: 'firstName', + }, + }, + }, + }); + + /** + * Step 2: Initiate SSO login for enforced workspace + */ + const response = await apiInstance.get( + `/auth/sso/saml/${enforcedWorkspace}`, + { + maxRedirects: 0, + validateStatus: (status) => status === 302, + } + ); + + /** + * Step 3: Verify SSO initiation works + */ + expect(response.status).toBe(302); + expect(response.headers.location).toContain(keycloakConfig.ssoUrl); + + await cleanupWorkspace(enforcedWorkspace); }); }); describe('Error Handling', () => { test('Should handle missing SAML configuration gracefully', async () => { /** - * TODO: Test error handling when workspace has SSO enabled - * but configuration is incomplete + * Test Plan: + * 1. Create workspace with SSO enabled but empty configuration + * 2. Try to initiate SSO login + * 3. Verify error response (400 or 500) + * + * Expected: API handles incomplete config gracefully with error + */ + + /** + * Step 1: Create workspace with incomplete SSO config */ - expect(true).toBe(true); + const incompleteWorkspace = await createTestWorkspace({ + name: 'Incomplete SSO Workspace', + sso: { + enabled: true, + enforced: false, + type: 'saml', + saml: { + idpEntityId: '', + ssoUrl: '', + x509Cert: '', + nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + attributeMapping: { + email: 'email', + }, + }, + }, + }); + + /** + * Step 2: Try to initiate SSO with incomplete config + */ + const response = await apiInstance.get( + `/auth/sso/saml/${incompleteWorkspace}`, + { + validateStatus: () => true, + } + ); + + /** + * Step 3: Verify error response + */ + expect([400, 500]).toContain(response.status); + + await cleanupWorkspace(incompleteWorkspace); }); test('Should handle IdP errors gracefully', async () => { /** - * TODO: Test handling of various IdP error responses + * Test Plan: + * 1. Mock IdP returning error in SAML Response + * 2. POST error SAML Response to ACS + * 3. Verify API handles IdP errors gracefully + * + * Note: This would require mocking various SAML error responses + * (e.g., authentication failure, request denied, etc.) + * To be implemented with proper SAML error response mocks + * + * Expected: API gracefully handles and displays IdP errors */ expect(true).toBe(true); }); @@ -132,19 +513,20 @@ describe('SSO Integration Tests', () => { }); /** - * NOTE: These are placeholder tests showing the structure. + * NOTE: Integration tests with Keycloak * - * To fully implement these tests, we need: - * 1. GraphQL test utilities for creating workspaces with SSO config - * 2. SAML Response mocks (valid and invalid) - * 3. Helper functions for simulating SSO flow - * 4. MongoDB test data setup/teardown + * These tests verify: + * 1. SSO initiation and redirect to Keycloak + * 2. ACS endpoint behavior (with mocked SAML Response) + * 3. SSO enforcement + * 4. Error handling * - * For now, the unit tests in test/sso/saml/ provide coverage for - * individual components (controller, service, store, utils). + * Limitations: + * - Mock SAML Response won't pass signature validation + * - For full end-to-end tests with real Keycloak SAML Response, + * browser automation (puppeteer/playwright) is needed * - * Future work: - * - Implement full integration tests with mocked SAML Responses - * - Add Keycloak container to docker-compose.test.yml - * - Create browser automation tests for full SSO flow + * Manual Testing: + * - See keycloak/README.md for manual testing instructions + * - Use Keycloak admin console to view test users and SAML configuration */ diff --git a/test/integration/jestEnv.js b/test/integration/jestEnv.js index 0249f971..cad69c67 100644 --- a/test/integration/jestEnv.js +++ b/test/integration/jestEnv.js @@ -13,6 +13,14 @@ class CustomEnvironment extends NodeEnvironment { */ async setup() { await super.setup(); + + /** + * Add performance API polyfill for MongoDB driver + * MongoDB driver uses performance.now() which is not available in Jest environment by default + */ + const { performance } = require('perf_hooks'); + this.global.performance = performance; + const mongoClient = new mongodb.MongoClient('mongodb://mongodb:27017', { useUnifiedTopology: true }); await mongoClient.connect(); diff --git a/test/integration/utils.ts b/test/integration/utils.ts index 26701341..53e1fcc7 100644 --- a/test/integration/utils.ts +++ b/test/integration/utils.ts @@ -18,3 +18,13 @@ export const accountingEnv = dotenv.config({ path: path.join(__dirname, './accou export const apiInstance = axios.create({ baseURL: `http://api:${apiEnv.PORT}`, }); + +/** + * Export Keycloak utilities + */ +export * from './utils/keycloak'; + +/** + * Export Workspace utilities + */ +export * from './utils/workspace'; diff --git a/test/integration/utils/keycloak.ts b/test/integration/utils/keycloak.ts new file mode 100644 index 00000000..48d1d808 --- /dev/null +++ b/test/integration/utils/keycloak.ts @@ -0,0 +1,297 @@ +import axios from 'axios'; +import { parseString } from 'xml2js'; +import { promisify } from 'util'; + +const parseXml = promisify(parseString); + +/** + * Keycloak configuration + */ +export const keycloakConfig = { + baseUrl: process.env.KEYCLOAK_URL || 'http://keycloak:8180', + realm: 'hawk', + clientId: 'hawk-sp', + adminUser: 'admin', + adminPassword: 'admin', +}; + +/** + * Test user credentials + */ +export const testUsers = { + testuser: { + username: 'testuser', + password: 'password123', + email: 'testuser@hawk.local', + firstName: 'Test', + lastName: 'User', + }, + alice: { + username: 'alice', + password: 'password123', + email: 'alice@hawk.local', + firstName: 'Alice', + lastName: 'Johnson', + }, + bob: { + username: 'bob', + password: 'password123', + email: 'bob@hawk.local', + firstName: 'Bob', + lastName: 'Smith', + }, +}; + +/** + * Keycloak SAML configuration for Hawk + */ +export interface KeycloakSamlConfig { + /** + * IdP Entity ID + */ + idpEntityId: string; + + /** + * SSO URL + */ + ssoUrl: string; + + /** + * X.509 Certificate (PEM format, without headers) + */ + x509Cert: string; +} + +/** + * Get Keycloak admin token + */ +export async function getAdminToken(): Promise { + const response = await axios.post( + `${keycloakConfig.baseUrl}/realms/master/protocol/openid-connect/token`, + new URLSearchParams({ + username: keycloakConfig.adminUser, + password: keycloakConfig.adminPassword, + grant_type: 'password', + client_id: 'admin-cli', + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); + + return response.data.access_token; +} + +/** + * Get Keycloak SAML configuration for Hawk + */ +export async function getKeycloakSamlConfig(): Promise { + /** + * Fetch SAML metadata descriptor + */ + const descriptorUrl = `${keycloakConfig.baseUrl}/realms/${keycloakConfig.realm}/protocol/saml/descriptor`; + const response = await axios.get(descriptorUrl); + + /** + * Parse XML to extract certificate + * xml2js handles namespaces by creating keys with namespace prefixes + * Keycloak uses 'md:' prefix for metadata elements and 'ds:' for signature elements + */ + const parsed: any = await parseXml(response.data); + + /** + * Access EntityDescriptor with namespace prefix + */ + const entityDescriptor = parsed['md:EntityDescriptor'] || parsed.EntityDescriptor; + + if (!entityDescriptor) { + throw new Error('EntityDescriptor not found in SAML metadata'); + } + + /** + * Access IDPSSODescriptor with namespace prefix + */ + const idpDescriptor = + entityDescriptor['md:IDPSSODescriptor']?.[0] || entityDescriptor.IDPSSODescriptor?.[0]; + + if (!idpDescriptor) { + throw new Error('IDPSSODescriptor not found in SAML metadata'); + } + + /** + * Find signing certificate from KeyDescriptor elements + */ + let x509Cert = ''; + const keyDescriptors = idpDescriptor['md:KeyDescriptor'] || idpDescriptor.KeyDescriptor || []; + + for (const kd of keyDescriptors) { + if (!kd.$?.use || kd.$?.use === 'signing') { + /** + * Try different possible paths for X509Certificate with namespace prefixes + */ + const keyInfo = kd['ds:KeyInfo']?.[0] || kd.KeyInfo?.[0]; + + if (keyInfo) { + const x509Data = keyInfo['ds:X509Data']?.[0] || keyInfo.X509Data?.[0]; + + if (x509Data) { + x509Cert = x509Data['ds:X509Certificate']?.[0] || x509Data.X509Certificate?.[0] || ''; + } + } + + if (x509Cert) { + break; + } + } + } + + if (!x509Cert) { + throw new Error('X509 Certificate not found in SAML metadata'); + } + + return { + idpEntityId: `${keycloakConfig.baseUrl}/realms/${keycloakConfig.realm}`, + ssoUrl: `${keycloakConfig.baseUrl}/realms/${keycloakConfig.realm}/protocol/saml`, + x509Cert: x509Cert.trim(), + }; +} + +/** + * Simulate SSO login flow and get SAML Response + * + * This function performs browser-like login to Keycloak and extracts the SAML Response + * + * @param username - Keycloak username + * @param password - Keycloak password + * @param acsUrl - ACS URL where SAML Response should be sent + * @returns SAML Response and RelayState + */ +export async function performKeycloakLogin( + username: string, + password: string, + acsUrl: string +): Promise<{ samlResponse: string; relayState?: string }> { + /** + * This is a simplified version. In a real test, you would need to: + * 1. Make a request to Hawk's SSO initiation endpoint + * 2. Follow redirects to Keycloak + * 3. Submit login form + * 4. Extract SAML Response from the POST to ACS + * + * For now, this is a placeholder that would require additional libraries + * like puppeteer or playwright for full browser automation. + */ + throw new Error('Browser automation not implemented. Use mock SAML Response for tests.'); +} + +/** + * Create a mock SAML Response for testing + * + * NOTE: This is a simplified mock. For real tests, you should either: + * - Use actual Keycloak-generated SAML Response (via browser automation) + * - Use a proper SAML Response generator library + * + * @param email - User email + * @param nameId - Name ID (usually email) + * @param attributes - Additional SAML attributes + * @returns Base64-encoded SAML Response + */ +export function createMockSamlResponse( + email: string, + nameId: string, + attributes: Record = {} +): string { + const now = new Date().toISOString(); + const notOnOrAfter = new Date(Date.now() + 300000).toISOString(); // 5 minutes + const issueInstant = now; + const sessionNotOnOrAfter = new Date(Date.now() + 3600000).toISOString(); // 1 hour + + /** + * This is a minimal SAML Response structure + * In production, this would be generated by the IdP (Keycloak) + */ + const samlResponse = ` + + ${keycloakConfig.baseUrl}/realms/${keycloakConfig.realm} + + + + + ${keycloakConfig.baseUrl}/realms/${keycloakConfig.realm} + + ${nameId} + + + + + + + hawk-sp + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + + ${email} + + ${attributes.name ? ` + + ${attributes.name} + ` : ''} + + +`; + + /** + * Base64 encode the SAML Response + */ + return Buffer.from(samlResponse).toString('base64'); +} + +/** + * Generate random ID for SAML messages + */ +function generateId(): string { + return '_' + Array.from({ length: 32 }, () => Math.random().toString(36)[2]).join(''); +} + +/** + * Wait for Keycloak to be ready + * + * @param maxRetries - Maximum number of retries + * @param retryInterval - Interval between retries in ms + */ +export async function waitForKeycloak(maxRetries = 30, retryInterval = 2000): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + await axios.get(`${keycloakConfig.baseUrl}/health/ready`); + return; + } catch (error) { + if (i === maxRetries - 1) { + throw new Error('Keycloak failed to start in time'); + } + await new Promise(resolve => setTimeout(resolve, retryInterval)); + } + } +} diff --git a/test/integration/utils/workspace.ts b/test/integration/utils/workspace.ts new file mode 100644 index 00000000..0437a98a --- /dev/null +++ b/test/integration/utils/workspace.ts @@ -0,0 +1,205 @@ +import { MongoClient, ObjectId } from 'mongodb'; +import type { WorkspaceDBScheme, UserDBScheme } from '@hawk.so/types'; +import argon2 from 'argon2'; + +/** + * Polyfill for performance API (required by MongoDB driver) + */ +if (typeof global.performance === 'undefined') { + const { performance } = require('perf_hooks'); + + global.performance = performance as any; +} + +/** + * Get MongoDB connection for tests + * Uses the same database as API (from MONGO_HAWK_DB_URL) to ensure data consistency + */ +export async function getMongoConnection(): Promise { + const mongoUrl = process.env.MONGO_HAWK_DB_URL || 'mongodb://mongodb:27017/hawk'; + const client = new MongoClient(mongoUrl); + + await client.connect(); + + return client; +} + +/** + * Create test workspace with SSO configuration + * + * @param config - Workspace configuration + * @returns Created workspace ID + */ +export async function createTestWorkspace(config: { + name?: string; + sso?: WorkspaceDBScheme['sso']; + members?: string[]; +}): Promise { + const client = await getMongoConnection(); + const db = client.db(); + const workspacesCollection = db.collection('workspaces'); + + /** + * Create minimal workspace data for tests + * Only required fields + SSO config + */ + const workspaceData: any = { + name: config.name || 'Test Workspace', + inviteHash: new ObjectId().toString(), + }; + + /** + * Add SSO config if provided + */ + if (config.sso) { + workspaceData.sso = config.sso; + } + + const result = await workspacesCollection.insertOne(workspaceData as WorkspaceDBScheme); + + await client.close(); + + return result.insertedId.toString(); +} + +/** + * Create test user + * + * @param config - User configuration + * @returns Created user ID + */ +export async function createTestUser(config: { + email: string; + password?: string; + name?: string; + workspaces?: string[]; +}): Promise { + const client = await getMongoConnection(); + const db = client.db(); + const usersCollection = db.collection('users'); + + /** + * Hash password if provided + */ + const hashedPassword = config.password ? await argon2.hash(config.password) : undefined; + + /** + * Build workspaces membership object + * Format: { [workspaceId]: { isPending: false } } + */ + const workspaces: Record = {}; + + if (config.workspaces && config.workspaces.length > 0) { + for (const workspaceId of config.workspaces) { + workspaces[workspaceId] = { isPending: false }; + } + } + + const userData: Partial = { + email: config.email, + password: hashedPassword, + name: config.name || config.email, + workspaces: Object.keys(workspaces).length > 0 ? workspaces : undefined, + notifications: { + channels: { + email: { + endpoint: config.email, + isEnabled: true, + minPeriod: 0, + }, + }, + whatToReceive: { + IssueAssigning: true, + WeeklyDigest: true, + SystemMessages: true, + }, + }, + }; + + const result = await usersCollection.insertOne(userData as UserDBScheme); + const userId = result.insertedId; + + /** + * Add user to workspace teams if workspaces specified + */ + if (config.workspaces && config.workspaces.length > 0) { + for (const workspaceId of config.workspaces) { + const teamCollection = db.collection(`team:${workspaceId}`); + + await teamCollection.insertOne({ + userId, + isConfirmed: true, + }); + } + } + + await client.close(); + + return userId.toString(); +} + +/** + * Get workspace by ID + * + * @param workspaceId - Workspace ID + * @returns Workspace data or null + */ +export async function getWorkspace(workspaceId: string): Promise { + const client = await getMongoConnection(); + const db = client.db(); + const workspacesCollection = db.collection('workspaces'); + + const workspace = await workspacesCollection.findOne({ _id: new ObjectId(workspaceId) }); + + await client.close(); + + return workspace; +} + +/** + * Get user by email + * + * @param email - User email + * @returns User data or null + */ +export async function getUserByEmail(email: string): Promise { + const client = await getMongoConnection(); + const db = client.db(); + const usersCollection = db.collection('users'); + + const user = await usersCollection.findOne({ email }); + + await client.close(); + + return user; +} + +/** + * Clean up test workspace + * + * @param workspaceId - Workspace ID to delete + */ +export async function cleanupWorkspace(workspaceId: string): Promise { + const client = await getMongoConnection(); + const db = client.db(); + const workspacesCollection = db.collection('workspaces'); + + await workspacesCollection.deleteOne({ _id: new ObjectId(workspaceId) }); + + await client.close(); +} + +/** + * Clean up test user + * + * @param email - User email to delete + */ +export async function cleanupUser(email: string): Promise { + const client = await getMongoConnection(); + const db = client.db(); + const usersCollection = db.collection('users'); + + await usersCollection.deleteOne({ email }); + + await client.close(); +} diff --git a/yarn.lock b/yarn.lock index c782c1f0..4f3e19a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -491,10 +491,10 @@ dependencies: "@types/mongodb" "^3.5.34" -"@hawk.so/types@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.4.0.tgz#76627aba4c253352e088a6c2b1358065908334ae" - integrity sha512-PiwrZn2xGIfCnFFapAZPSXC75cdMOUewV3LTMZijF9lBgUHI2fIgbcMdT65WAkDFArtQTYzoLhDvJMbhtEyRKA== +"@hawk.so/types@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.4.2.tgz#85482495a951de47ba8be88725d56ab2d72184bc" + integrity sha512-0eY/XYhloRiTq3M7d76WrRToVsDjSxXP/gMtBbbNc0qo9RFjmrS4JKsNx52EQVPdBBdi6s6njcGF1DrhYCrQUA== dependencies: bson "^7.0.0" From cf3c050e1e76fea302fc5591dbd9015b0295e15e Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:52:40 +0000 Subject: [PATCH 27/33] Bump version up to 1.2.33 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c190f8cd..75a27ca3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.2.32", + "version": "1.2.33", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From 71901088e2110cf2964db1389851b6d03bf7f598 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 15 Jan 2026 19:53:16 +0300 Subject: [PATCH 28/33] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 75a27ca3..f917baad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.2.33", + "version": "1.3.0", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From 1080020b4b27412c7e967d6cf15094a3a6af22f9 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 15 Jan 2026 22:11:39 +0300 Subject: [PATCH 29/33] lint --- src/directives/definedOnlyForAdmins.ts | 2 - src/models/user.ts | 12 ++- src/resolvers/workspace.js | 2 +- src/sso/index.ts | 2 +- src/sso/saml/controller.ts | 116 ++++++++++++++++--------- src/sso/saml/index.ts | 1 - src/sso/saml/service.ts | 63 +++++++------- src/sso/saml/store.ts | 44 ++++++---- src/sso/saml/types.ts | 7 +- src/sso/saml/utils.ts | 1 - src/sso/types.ts | 3 +- test/integration/cases/sso.test.ts | 1 - 12 files changed, 149 insertions(+), 105 deletions(-) diff --git a/src/directives/definedOnlyForAdmins.ts b/src/directives/definedOnlyForAdmins.ts index 279b94f6..8a95295e 100644 --- a/src/directives/definedOnlyForAdmins.ts +++ b/src/directives/definedOnlyForAdmins.ts @@ -1,7 +1,6 @@ import { defaultFieldResolver, GraphQLSchema } from 'graphql'; import { mapSchema, MapperKind, getDirective } from '@graphql-tools/utils'; import { ResolverContextWithUser, UnknownGraphQLResolverResult } from '../types/graphql'; -import { ForbiddenError, UserInputError } from 'apollo-server-express'; import WorkspaceModel from '../models/workspace'; /** @@ -98,4 +97,3 @@ export default function definedOnlyForAdminsDirective(directiveName = 'definedOn }), }; } - diff --git a/src/models/user.ts b/src/models/user.ts index 468077c4..26c696db 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -473,9 +473,17 @@ export default class UserModel extends AbstractModel> this.identities = {}; } if (!this.identities[workspaceId]) { - this.identities[workspaceId] = { saml: { id: samlId, email } }; + this.identities[workspaceId] = { + saml: { + id: samlId, + email, + }, + }; } else { - this.identities[workspaceId].saml = { id: samlId, email }; + this.identities[workspaceId].saml = { + id: samlId, + email, + }; } } diff --git a/src/resolvers/workspace.js b/src/resolvers/workspace.js index 4565b640..2333099d 100644 --- a/src/resolvers/workspace.js +++ b/src/resolvers/workspace.js @@ -49,7 +49,7 @@ module.exports = { /** * Check if workspace exists and has SSO enabled */ - if (!workspace || !workspace.sso?.enabled) { + if (!workspace || !(workspace.sso && workspace.sso.enabled)) { return null; } diff --git a/src/sso/index.ts b/src/sso/index.ts index bfc76739..cbe28ea7 100644 --- a/src/sso/index.ts +++ b/src/sso/index.ts @@ -10,6 +10,6 @@ import { ContextFactories } from '../types/graphql'; */ export function appendSsoRoutes(app: express.Application, factories: ContextFactories): void { const samlRouter = createSamlRouter(factories); + app.use('/auth/sso/saml', samlRouter); } - diff --git a/src/sso/saml/controller.ts b/src/sso/saml/controller.ts index 6d813a00..f1c3425b 100644 --- a/src/sso/saml/controller.ts +++ b/src/sso/saml/controller.ts @@ -23,54 +23,19 @@ export default class SamlController { */ private factories: ContextFactories; + /** + * SAML controller constructor used for DI + * @param factories - for working with models + */ constructor(factories: ContextFactories) { this.samlService = new SamlService(); this.factories = factories; } - /** - * Log message with SSO prefix - * - * @param level - log level ('log', 'warn', 'error', 'info', 'success') - * @param args - arguments to log - */ - private log(level: 'log' | 'warn' | 'error' | 'info' | 'success', ...args: unknown[]): void { - const colors = { - log: Effect.ForegroundGreen, - warn: Effect.ForegroundYellow, - error: Effect.ForegroundRed, - info: Effect.ForegroundBlue, - success: [Effect.ForegroundGreen, Effect.Bold], - }; - - const logger = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; - - logger(sgr('[SSO]', colors[level]), ...args); - } - - /** - * Validate workspace ID format - * - * @param workspaceId - workspace ID to validate - * @returns true if valid, false otherwise - */ - private isValidWorkspaceId(workspaceId: string): boolean { - return ObjectId.isValid(workspaceId); - } - - /** - * Compose Assertion Consumer Service URL for workspace - * - * @param workspaceId - workspace ID - * @returns ACS URL - */ - private getAcsUrl(workspaceId: string): string { - const apiUrl = process.env.API_URL || 'https://api.hawk.so'; - return `${apiUrl}/auth/sso/saml/${workspaceId}/acs`; - } - /** * Initiate SSO login (GET /auth/sso/saml/:workspaceId) + * @param req - Express request + * @param res - Express response */ public async initiateLogin(req: express.Request, res: express.Response): Promise { const { workspaceId } = req.params; @@ -84,6 +49,7 @@ export default class SamlController { if (!this.isValidWorkspaceId(workspaceId)) { this.log('warn', 'Invalid workspace ID format:', sgr(workspaceId, Effect.ForegroundRed)); res.status(400).json({ error: 'Invalid workspace ID' }); + return; } @@ -95,6 +61,7 @@ export default class SamlController { if (!workspace || !workspace.sso?.enabled) { this.log('warn', 'SSO not enabled for workspace:', sgr(workspaceId, Effect.ForegroundCyan)); res.status(400).json({ error: 'SSO is not enabled for this workspace' }); + return; } @@ -107,7 +74,10 @@ export default class SamlController { /** * 3. Save RelayState to temporary storage */ - samlStore.saveRelayState(relayStateId, { returnUrl, workspaceId }); + samlStore.saveRelayState(relayStateId, { + returnUrl, + workspaceId, + }); /** * 4. Generate AuthnRequest @@ -141,6 +111,7 @@ export default class SamlController { * 6. Redirect to IdP */ const redirectUrl = new URL(workspace.sso.saml.ssoUrl); + redirectUrl.searchParams.set('SAMLRequest', encodedRequest); redirectUrl.searchParams.set('RelayState', relayStateId); @@ -167,6 +138,9 @@ export default class SamlController { /** * Handle ACS callback (POST /auth/sso/saml/:workspaceId/acs) + * @param req - Express request object + * @param res - Express response object + * @returns void */ public async handleAcs(req: express.Request, res: express.Response): Promise { const { workspaceId } = req.params; @@ -181,6 +155,7 @@ export default class SamlController { if (!this.isValidWorkspaceId(workspaceId)) { this.log('warn', '[ACS] Invalid workspace ID format:', sgr(workspaceId, Effect.ForegroundRed)); res.status(400).json({ error: 'Invalid workspace ID' }); + return; } @@ -190,6 +165,7 @@ export default class SamlController { if (!samlResponse) { this.log('warn', '[ACS] Missing SAML response for workspace:', sgr(workspaceId, Effect.ForegroundCyan)); res.status(400).json({ error: 'SAML response is required' }); + return; } @@ -201,6 +177,7 @@ export default class SamlController { if (!workspace || !workspace.sso?.enabled) { this.log('warn', '[ACS] SSO not enabled for workspace:', sgr(workspaceId, Effect.ForegroundCyan)); res.status(400).json({ error: 'SSO is not enabled for this workspace' }); + return; } @@ -249,6 +226,7 @@ export default class SamlController { sgr(samlData.inResponseTo.slice(0, 8), Effect.ForegroundGray) ); res.status(400).json({ error: 'Invalid SAML response: InResponseTo validation failed' }); + return; } } @@ -261,6 +239,7 @@ export default class SamlController { sgr(error instanceof Error ? error.message : 'Unknown error', Effect.ForegroundRed) ); res.status(400).json({ error: 'Invalid SAML response' }); + return; } @@ -310,6 +289,7 @@ export default class SamlController { */ const callbackPath = `/login/sso/${workspaceId}`; const frontendUrl = new URL(callbackPath, process.env.GARAGE_URL || 'http://localhost:3000'); + frontendUrl.searchParams.set('access_token', tokens.accessToken); frontendUrl.searchParams.set('refresh_token', tokens.refreshToken); frontendUrl.searchParams.set('returnUrl', finalReturnUrl); @@ -340,6 +320,7 @@ export default class SamlController { sgr(error.message, Effect.ForegroundRed) ); res.status(400).json({ error: 'Invalid SAML response' }); + return; } @@ -354,6 +335,56 @@ export default class SamlController { } } + /** + * Log message with SSO prefix + * + * @param level - log level ('log', 'warn', 'error', 'info', 'success') + * @param args - arguments to log + */ + private log(level: 'log' | 'warn' | 'error' | 'info' | 'success', ...args: unknown[]): void { + const colors = { + log: Effect.ForegroundGreen, + warn: Effect.ForegroundYellow, + error: Effect.ForegroundRed, + info: Effect.ForegroundBlue, + success: [Effect.ForegroundGreen, Effect.Bold], + }; + + let logger: typeof console.log; + + if (level === 'error') { + logger = console.error; + } else if (level === 'warn') { + logger = console.warn; + } else { + logger = console.log; + } + + logger(sgr('[SSO]', colors[level]), ...args); + } + + /** + * Validate workspace ID format + * + * @param workspaceId - workspace ID to validate + * @returns true if valid, false otherwise + */ + private isValidWorkspaceId(workspaceId: string): boolean { + return ObjectId.isValid(workspaceId); + } + + /** + * Compose Assertion Consumer Service URL for workspace + * + * @param workspaceId - workspace ID + * @returns ACS URL + */ + private getAcsUrl(workspaceId: string): string { + const apiUrl = process.env.API_URL || 'https://api.hawk.so'; + + return `${apiUrl}/auth/sso/saml/${workspaceId}/acs`; + } + /** * Handle user provisioning (JIT or invite-only) * @@ -462,4 +493,3 @@ export default class SamlController { } } } - diff --git a/src/sso/saml/index.ts b/src/sso/saml/index.ts index 23596358..61eb3d64 100644 --- a/src/sso/saml/index.ts +++ b/src/sso/saml/index.ts @@ -38,4 +38,3 @@ export function createSamlRouter(factories: ContextFactories): express.Router { return router; } - diff --git a/src/sso/saml/service.ts b/src/sso/saml/service.ts index 869ec952..b7236f6a 100644 --- a/src/sso/saml/service.ts +++ b/src/sso/saml/service.ts @@ -1,4 +1,5 @@ import { SAML, SamlConfig as NodeSamlConfig, Profile } from '@node-saml/node-saml'; +import { inflateRawSync } from 'zlib'; import { SamlConfig, SamlResponseData } from '../types'; import { SamlValidationError, SamlValidationErrorType } from './types'; import { extractAttribute } from './utils'; @@ -51,34 +52,6 @@ export default class SamlService { }; } - /** - * Extract request ID from encoded SAML AuthnRequest - * - * @param encodedRequest - deflated and base64 encoded SAML request - * @returns request ID - */ - private extractRequestIdFromEncodedRequest(encodedRequest: string): string { - const zlib = require('zlib'); - - /** - * Decode base64 and inflate - */ - const decoded = Buffer.from(encodedRequest, 'base64'); - const inflated = zlib.inflateRawSync(decoded).toString('utf-8'); - - /** - * Extract ID attribute from AuthnRequest XML - * Format: - */ - const idMatch = inflated.match(/ID="([^"]+)"/); - - if (!idMatch || !idMatch[1]) { - throw new Error('Failed to extract request ID from AuthnRequest'); - } - - return idMatch[1]; - } - /** * Validate and parse SAML Response * @@ -179,7 +152,10 @@ export default class SamlService { throw new SamlValidationError( SamlValidationErrorType.INVALID_IN_RESPONSE_TO, `InResponseTo mismatch: expected ${expectedRequestId}, got ${inResponseTo}`, - { expected: expectedRequestId, received: inResponseTo } + { + expected: expectedRequestId, + received: inResponseTo, + } ); } @@ -219,6 +195,32 @@ export default class SamlService { }; } + /** + * Extract request ID from encoded SAML AuthnRequest + * + * @param encodedRequest - deflated and base64 encoded SAML request + * @returns request ID + */ + private extractRequestIdFromEncodedRequest(encodedRequest: string): string { + /** + * Decode base64 and inflate + */ + const decoded = Buffer.from(encodedRequest, 'base64'); + const inflated = inflateRawSync(decoded as unknown as Uint8Array).toString('utf-8'); + + /** + * Extract ID attribute from AuthnRequest XML + * Format: + */ + const idMatch = inflated.match(/ID="([^"]+)"/); + + if (!idMatch || !idMatch[1]) { + throw new Error('Failed to extract request ID from AuthnRequest'); + } + + return idMatch[1]; + } + /** * Create node-saml SAML instance with given configuration * @@ -226,7 +228,7 @@ export default class SamlService { * @param samlConfig - SAML configuration from workspace * @returns configured SAML instance */ - private createSamlInstance(acsUrl: string, samlConfig: SamlConfig): SAML { + private createSamlInstance(acsUrl: string, samlConfig: SamlConfig): SAML { const spEntityId = process.env.SSO_SP_ENTITY_ID; if (!spEntityId) { @@ -254,4 +256,3 @@ export default class SamlService { return new SAML(options); } } - diff --git a/src/sso/saml/store.ts b/src/sso/saml/store.ts index b2ca8121..4e09dc0b 100644 --- a/src/sso/saml/store.ts +++ b/src/sso/saml/store.ts @@ -28,6 +28,9 @@ class SamlStateStore { */ private cleanupTimer: NodeJS.Timeout | null = null; + /** + * Store constructor + */ constructor() { this.startCleanupTimer(); } @@ -72,7 +75,10 @@ class SamlStateStore { */ this.relayStates.delete(stateId); - return { returnUrl: state.returnUrl, workspaceId: state.workspaceId }; + return { + returnUrl: state.returnUrl, + workspaceId: state.workspaceId, + }; } /** @@ -126,6 +132,24 @@ class SamlStateStore { return true; } + /** + * Stop cleanup timer (for testing) + */ + public stopCleanupTimer(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + } + + /** + * Clear all stored state (for testing) + */ + public clear(): void { + this.relayStates.clear(); + this.authnRequests.clear(); + } + /** * Start periodic cleanup of expired entries */ @@ -165,24 +189,6 @@ class SamlStateStore { } } } - - /** - * Stop cleanup timer (for testing) - */ - public stopCleanupTimer(): void { - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - this.cleanupTimer = null; - } - } - - /** - * Clear all stored state (for testing) - */ - public clear(): void { - this.relayStates.clear(); - this.authnRequests.clear(); - } } /** diff --git a/src/sso/saml/types.ts b/src/sso/saml/types.ts index b57c0b4f..3a964969 100644 --- a/src/sso/saml/types.ts +++ b/src/sso/saml/types.ts @@ -35,6 +35,12 @@ export class SamlValidationError extends Error { */ public readonly context?: Record; + /** + * Error construcor + * @param type - error kind, see SamlValidationErrorType + * @param message - string message + * @param context - additional data + */ constructor(type: SamlValidationErrorType, message: string, context?: Record) { super(message); this.name = 'SamlValidationError'; @@ -77,4 +83,3 @@ export interface RelayStateData { */ expiresAt: number; } - diff --git a/src/sso/saml/utils.ts b/src/sso/saml/utils.ts index 3ad65430..cee5828b 100644 --- a/src/sso/saml/utils.ts +++ b/src/sso/saml/utils.ts @@ -22,4 +22,3 @@ export function extractAttribute(attributes: Record, return undefined; } - diff --git a/src/sso/types.ts b/src/sso/types.ts index 234a8cc6..aef70830 100644 --- a/src/sso/types.ts +++ b/src/sso/types.ts @@ -4,7 +4,7 @@ export type { SamlAttributeMapping, SamlConfig, - WorkspaceSsoConfig, + WorkspaceSsoConfig } from '@hawk.so/types'; /** @@ -33,4 +33,3 @@ export interface SamlResponseData { */ inResponseTo?: string; } - diff --git a/test/integration/cases/sso.test.ts b/test/integration/cases/sso.test.ts index 14e6619b..f35c5d4d 100644 --- a/test/integration/cases/sso.test.ts +++ b/test/integration/cases/sso.test.ts @@ -8,7 +8,6 @@ import { createTestUser, cleanupWorkspace, cleanupUser, - getUserByEmail, } from '../utils'; import { ObjectId } from 'mongodb'; From 73a89e6138b8e2071eb9a18e3048e97c9faf6981 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 15 Jan 2026 22:14:08 +0300 Subject: [PATCH 30/33] fix tests --- test/integration/cases/sso.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/test/integration/cases/sso.test.ts b/test/integration/cases/sso.test.ts index f35c5d4d..b983aa1c 100644 --- a/test/integration/cases/sso.test.ts +++ b/test/integration/cases/sso.test.ts @@ -7,7 +7,7 @@ import { createTestWorkspace, createTestUser, cleanupWorkspace, - cleanupUser, + cleanupUser } from '../utils'; import { ObjectId } from 'mongodb'; @@ -34,7 +34,7 @@ describe('SSO Integration Tests', () => { * Get Keycloak SAML configuration */ keycloakConfig = await getKeycloakSamlConfig(); - }, 60000); + }, 60000); /** * Create test workspace before each test @@ -184,7 +184,6 @@ describe('SSO Integration Tests', () => { }); describe('ACS (Assertion Consumer Service)', () => { - /** * This test requires full E2E flow with browser automation * @@ -197,8 +196,7 @@ describe('SSO Integration Tests', () => { * 7. Verify user was logged in * 8. Verify user was redirected to the correct return URL with tokens */ - test.todo('Should process valid SAML Response and create user session', async () => { - }); + test.todo('Should process valid SAML Response and create user session'); test('Should reject invalid SAML Response', async () => { /** @@ -249,7 +247,7 @@ describe('SSO Integration Tests', () => { await createTestUser({ email: testUser.email, name: testUser.firstName, - workspaces: [testWorkspaceId], + workspaces: [ testWorkspaceId ], }); /** @@ -352,7 +350,7 @@ describe('SSO Integration Tests', () => { email: testUser.email, password: testUser.password, name: testUser.firstName, - workspaces: [enforcedWorkspace], + workspaces: [ enforcedWorkspace ], }); /** From de0834585a045bdaa6338edeec6868ef65353d3c Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 15 Jan 2026 23:13:46 +0300 Subject: [PATCH 31/33] Add pluggable SAML state store with Redis and memory support Refactored SAML state management to support both Redis and in-memory stores via a new SamlStateStoreInterface. Added Redis-backed implementation for multi-instance deployments and a factory to select the store type based on the SAML_STORE_TYPE environment variable. Updated controller and router to use the new store abstraction, and extended environment and type definitions accordingly. --- .env.sample | 3 + .env.test | 3 + src/redisHelper.ts | 9 + src/sso/saml/controller.ts | 83 +++++++- src/sso/saml/index.ts | 4 +- src/sso/saml/store/SamlStateStoreInterface.ts | 61 ++++++ .../saml/{store.ts => store/memory.store.ts} | 26 +-- src/sso/saml/store/redis.store.ts | 177 ++++++++++++++++++ src/sso/saml/storeFactory.ts | 49 +++++ src/types/env.d.ts | 11 ++ 10 files changed, 407 insertions(+), 19 deletions(-) create mode 100644 src/sso/saml/store/SamlStateStoreInterface.ts rename src/sso/saml/{store.ts => store/memory.store.ts} (81%) create mode 100644 src/sso/saml/store/redis.store.ts create mode 100644 src/sso/saml/storeFactory.ts diff --git a/.env.sample b/.env.sample index 2ca4d853..2dd99082 100644 --- a/.env.sample +++ b/.env.sample @@ -90,3 +90,6 @@ AWS_S3_BUCKET_ENDPOINT= # SSO Service Provider Entity ID # Unique identifier for Hawk in SAML IdP configuration SSO_SP_ENTITY_ID=urn:hawk:tracker:saml + +## SAML state store type (memory or redis, default: redis) +SAML_STORE_TYPE=redis diff --git a/.env.test b/.env.test index 640d5377..19787b59 100644 --- a/.env.test +++ b/.env.test @@ -97,3 +97,6 @@ AWS_S3_SECRET_ACCESS_KEY= AWS_S3_BUCKET_NAME= AWS_S3_BUCKET_BASE_URL= AWS_S3_BUCKET_ENDPOINT= + +## SAML state store type (memory or redis, default: redis) +SAML_STORE_TYPE=memory diff --git a/src/redisHelper.ts b/src/redisHelper.ts index 82e64ef0..61a82e6a 100644 --- a/src/redisHelper.ts +++ b/src/redisHelper.ts @@ -139,6 +139,15 @@ export default class RedisHelper { return Boolean(this.redisClient?.isOpen); } + /** + * Get Redis client instance + * + * @returns Redis client or null if not initialized + */ + public getClient(): RedisClientType | null { + return this.redisClient; + } + /** * Execute TS.RANGE command with aggregation * diff --git a/src/sso/saml/controller.ts b/src/sso/saml/controller.ts index f1c3425b..c4cf111e 100644 --- a/src/sso/saml/controller.ts +++ b/src/sso/saml/controller.ts @@ -2,7 +2,7 @@ import express from 'express'; import { v4 as uuid } from 'uuid'; import { ObjectId } from 'mongodb'; import SamlService from './service'; -import samlStore from './store'; +import { SamlStateStoreInterface } from './store/SamlStateStoreInterface'; import { ContextFactories } from '../../types/graphql'; import { SamlResponseData } from '../types'; import WorkspaceModel from '../../models/workspace'; @@ -23,13 +23,21 @@ export default class SamlController { */ private factories: ContextFactories; + /** + * SAML state store instance + */ + private store: SamlStateStoreInterface; + /** * SAML controller constructor used for DI + * * @param factories - for working with models + * @param store - SAML state store instance */ - constructor(factories: ContextFactories) { + constructor(factories: ContextFactories, store: SamlStateStoreInterface) { this.samlService = new SamlService(); this.factories = factories; + this.store = store; } /** @@ -74,10 +82,20 @@ export default class SamlController { /** * 3. Save RelayState to temporary storage */ - samlStore.saveRelayState(relayStateId, { + this.log( + 'info', + '[Store] Saving RelayState:', + sgr(relayStateId.slice(0, 8), Effect.ForegroundGray), + '| Store:', + sgr(this.store.type, Effect.ForegroundBlue), + '| Workspace:', + sgr(workspaceId, Effect.ForegroundCyan) + ); + await this.store.saveRelayState(relayStateId, { returnUrl, workspaceId, }); + this.log('log', '[Store] RelayState saved:', sgr(relayStateId.slice(0, 8), Effect.ForegroundGray)); /** * 4. Generate AuthnRequest @@ -105,7 +123,17 @@ export default class SamlController { /** * 5. Save AuthnRequest ID for InResponseTo validation */ - samlStore.saveAuthnRequest(requestId, workspaceId); + this.log( + 'info', + '[Store] Saving AuthnRequest:', + sgr(requestId.slice(0, 8), Effect.ForegroundGray), + '| Store:', + sgr(this.store.type, Effect.ForegroundBlue), + '| Workspace:', + sgr(workspaceId, Effect.ForegroundCyan) + ); + await this.store.saveAuthnRequest(requestId, workspaceId); + this.log('log', '[Store] AuthnRequest saved:', sgr(requestId.slice(0, 8), Effect.ForegroundGray)); /** * 6. Redirect to IdP @@ -212,11 +240,34 @@ export default class SamlController { * Validate InResponseTo against stored AuthnRequest */ if (samlData.inResponseTo) { - const isValidRequest = samlStore.validateAndConsumeAuthnRequest( + this.log( + 'info', + '[Store] Validating AuthnRequest:', + sgr(samlData.inResponseTo.slice(0, 8), Effect.ForegroundGray), + '| Store:', + sgr(this.store.type, Effect.ForegroundBlue), + '| Workspace:', + sgr(workspaceId, Effect.ForegroundCyan) + ); + const isValidRequest = await this.store.validateAndConsumeAuthnRequest( samlData.inResponseTo, workspaceId ); + if (isValidRequest) { + this.log( + 'log', + '[Store] AuthnRequest validated and consumed:', + sgr(samlData.inResponseTo.slice(0, 8), Effect.ForegroundGray) + ); + } else { + this.log( + 'warn', + '[Store] AuthnRequest validation failed:', + sgr(samlData.inResponseTo.slice(0, 8), Effect.ForegroundRed) + ); + } + if (!isValidRequest) { this.log( 'error', @@ -274,7 +325,27 @@ export default class SamlController { * 4. Get RelayState for return URL (before consuming) * Note: RelayState is consumed after first use, so we need to get it before validation */ - const relayState = samlStore.getRelayState(relayStateId); + this.log( + 'info', + '[Store] Getting RelayState:', + sgr(relayStateId.slice(0, 8), Effect.ForegroundGray), + '| Store:', + sgr(this.store.type, Effect.ForegroundBlue) + ); + const relayState = await this.store.getRelayState(relayStateId); + + if (relayState) { + this.log( + 'log', + '[Store] RelayState retrieved and consumed:', + sgr(relayStateId.slice(0, 8), Effect.ForegroundGray), + '| Return URL:', + sgr(relayState.returnUrl, Effect.ForegroundGray) + ); + } else { + this.log('warn', '[Store] RelayState not found or expired:', sgr(relayStateId.slice(0, 8), Effect.ForegroundRed)); + } + const finalReturnUrl = relayState?.returnUrl || `/workspace/${workspaceId}`; /** diff --git a/src/sso/saml/index.ts b/src/sso/saml/index.ts index 61eb3d64..f7b09308 100644 --- a/src/sso/saml/index.ts +++ b/src/sso/saml/index.ts @@ -1,5 +1,6 @@ import express from 'express'; import SamlController from './controller'; +import { createSamlStateStore } from './storeFactory'; import { ContextFactories } from '../../types/graphql'; /** @@ -10,7 +11,8 @@ import { ContextFactories } from '../../types/graphql'; */ export function createSamlRouter(factories: ContextFactories): express.Router { const router = express.Router(); - const controller = new SamlController(factories); + const store = createSamlStateStore(); + const controller = new SamlController(factories, store); /** * SSO login initiation diff --git a/src/sso/saml/store/SamlStateStoreInterface.ts b/src/sso/saml/store/SamlStateStoreInterface.ts new file mode 100644 index 00000000..b2607095 --- /dev/null +++ b/src/sso/saml/store/SamlStateStoreInterface.ts @@ -0,0 +1,61 @@ +/** + * Interface for SAML state store implementations + * + * Defines contract for storing temporary SAML authentication state: + * - RelayState: maps state ID to return URL and workspace ID + * - AuthnRequests: maps request ID to workspace ID for InResponseTo validation + */ +export interface SamlStateStoreInterface { + /** + * Store type identifier + * Used for logging and debugging purposes + * + * @example "redis" or "memory" + */ + readonly type: string; + + /** + * Save RelayState data + * + * @param stateId - unique state identifier (usually UUID) + * @param data - relay state data (returnUrl, workspaceId) + */ + saveRelayState(stateId: string, data: { returnUrl: string; workspaceId: string }): Promise; + + /** + * Get and consume RelayState data + * + * @param stateId - state identifier + * @returns relay state data or null if not found/expired + */ + getRelayState(stateId: string): Promise<{ returnUrl: string; workspaceId: string } | null>; + + /** + * Save AuthnRequest for InResponseTo validation + * + * @param requestId - SAML AuthnRequest ID + * @param workspaceId - workspace ID + */ + saveAuthnRequest(requestId: string, workspaceId: string): Promise; + + /** + * Validate and consume AuthnRequest + * + * @param requestId - SAML AuthnRequest ID (from InResponseTo) + * @param workspaceId - expected workspace ID + * @returns true if request is valid and matches workspace + */ + validateAndConsumeAuthnRequest(requestId: string, workspaceId: string): Promise; + + /** + * Stop cleanup timer (for testing) + * Optional method - only needed for in-memory store + */ + stopCleanupTimer?(): void; + + /** + * Clear all stored state (for testing) + * Optional method - only needed for in-memory store + */ + clear?(): void; +} diff --git a/src/sso/saml/store.ts b/src/sso/saml/store/memory.store.ts similarity index 81% rename from src/sso/saml/store.ts rename to src/sso/saml/store/memory.store.ts index 4e09dc0b..b05f0e11 100644 --- a/src/sso/saml/store.ts +++ b/src/sso/saml/store/memory.store.ts @@ -1,4 +1,5 @@ -import { AuthnRequestState, RelayStateData } from './types'; +import { AuthnRequestState, RelayStateData } from '../types'; +import { SamlStateStoreInterface } from './SamlStateStoreInterface'; /** * In-memory store for SAML state @@ -7,9 +8,15 @@ import { AuthnRequestState, RelayStateData } from './types'; * - RelayState: maps state ID to return URL and workspace ID * - AuthnRequests: maps request ID to workspace ID for InResponseTo validation * - * @todo Replace with Redis for production (multi-instance support) + * Note: This implementation is not suitable for multi-instance deployments. + * Use Redis store for production environments with multiple API instances. */ -class SamlStateStore { +export class MemorySamlStateStore implements SamlStateStoreInterface { + /** + * Store type identifier + */ + public readonly type = 'memory'; + private relayStates: Map = new Map(); private authnRequests: Map = new Map(); @@ -41,7 +48,7 @@ class SamlStateStore { * @param stateId - unique state identifier (usually UUID) * @param data - relay state data (returnUrl, workspaceId) */ - public saveRelayState(stateId: string, data: { returnUrl: string; workspaceId: string }): void { + public async saveRelayState(stateId: string, data: { returnUrl: string; workspaceId: string }): Promise { this.relayStates.set(stateId, { ...data, expiresAt: Date.now() + this.TTL, @@ -54,7 +61,7 @@ class SamlStateStore { * @param stateId - state identifier * @returns relay state data or null if not found/expired */ - public getRelayState(stateId: string): { returnUrl: string; workspaceId: string } | null { + public async getRelayState(stateId: string): Promise<{ returnUrl: string; workspaceId: string } | null> { const state = this.relayStates.get(stateId); if (!state) { @@ -87,7 +94,7 @@ class SamlStateStore { * @param requestId - SAML AuthnRequest ID * @param workspaceId - workspace ID */ - public saveAuthnRequest(requestId: string, workspaceId: string): void { + public async saveAuthnRequest(requestId: string, workspaceId: string): Promise { this.authnRequests.set(requestId, { workspaceId, expiresAt: Date.now() + this.TTL, @@ -101,7 +108,7 @@ class SamlStateStore { * @param workspaceId - expected workspace ID * @returns true if request is valid and matches workspace */ - public validateAndConsumeAuthnRequest(requestId: string, workspaceId: string): boolean { + public async validateAndConsumeAuthnRequest(requestId: string, workspaceId: string): Promise { const request = this.authnRequests.get(requestId); if (!request) { @@ -190,8 +197,3 @@ class SamlStateStore { } } } - -/** - * Singleton instance - */ -export default new SamlStateStore(); diff --git a/src/sso/saml/store/redis.store.ts b/src/sso/saml/store/redis.store.ts new file mode 100644 index 00000000..a4c2a347 --- /dev/null +++ b/src/sso/saml/store/redis.store.ts @@ -0,0 +1,177 @@ +import { RedisClientType } from 'redis'; +import RedisHelper from '../../../redisHelper'; +import { SamlStateStoreInterface } from './SamlStateStoreInterface'; + +/** + * Redis-based store for SAML state + * + * Stores temporary data needed for SAML authentication flow in Redis: + * - RelayState: maps state ID to return URL and workspace ID + * - AuthnRequests: maps request ID to workspace ID for InResponseTo validation + * + * This implementation is suitable for multi-instance deployments as it uses + * Redis as the shared state store. TTLs are handled by Redis automatically. + */ +export class RedisSamlStateStore implements SamlStateStoreInterface { + /** + * Store type identifier + */ + public readonly type = 'redis'; + + /** + * Redis helper instance + */ + private redisHelper: RedisHelper; + + /** + * Time-to-live for stored state in seconds (5 minutes) + */ + private readonly TTL_SECONDS = 5 * 60; + + /** + * Prefix for RelayState keys in Redis + */ + private readonly RELAY_STATE_PREFIX = 'saml:relayState:'; + + /** + * Prefix for AuthnRequest keys in Redis + */ + private readonly AUTHN_REQUEST_PREFIX = 'saml:authnRequest:'; + + /** + * Store constructor + * + * @param redisHelper - Redis helper instance (defaults to singleton) + */ + constructor(redisHelper?: RedisHelper) { + this.redisHelper = redisHelper || RedisHelper.getInstance(); + } + + /** + * Save RelayState data + * + * @param stateId - unique state identifier (usually UUID) + * @param data - relay state data (returnUrl, workspaceId) + */ + public async saveRelayState(stateId: string, data: { returnUrl: string; workspaceId: string }): Promise { + const client = this.getClient(); + const key = `${this.RELAY_STATE_PREFIX}${stateId}`; + const value = JSON.stringify(data); + + await client.setEx(key, this.TTL_SECONDS, value); + } + + /** + * Get and consume RelayState data + * + * @param stateId - state identifier + * @returns relay state data or null if not found/expired + */ + public async getRelayState(stateId: string): Promise<{ returnUrl: string; workspaceId: string } | null> { + const client = this.getClient(); + const key = `${this.RELAY_STATE_PREFIX}${stateId}`; + + /** + * Get and delete atomically to prevent race conditions + * This ensures the state can only be consumed once + * Using MULTI/EXEC for atomic operation (compatible with Redis 5.0+) + */ + const results = await client + .multi() + .get(key) + .del(key) + .exec(); + + if (!results || results.length < 2) { + return null; + } + + const value = results[0] as string | null; + + if (!value) { + return null; + } + + try { + return JSON.parse(value) as { returnUrl: string; workspaceId: string }; + } catch (error) { + console.error('[Redis SAML Store] Failed to parse RelayState:', error); + + return null; + } + } + + /** + * Save AuthnRequest for InResponseTo validation + * + * @param requestId - SAML AuthnRequest ID + * @param workspaceId - workspace ID + */ + public async saveAuthnRequest(requestId: string, workspaceId: string): Promise { + const client = this.getClient(); + const key = `${this.AUTHN_REQUEST_PREFIX}${requestId}`; + + /** + * Store workspaceId as value + */ + await client.setEx(key, this.TTL_SECONDS, workspaceId); + } + + /** + * Validate and consume AuthnRequest + * + * @param requestId - SAML AuthnRequest ID (from InResponseTo) + * @param workspaceId - expected workspace ID + * @returns true if request is valid and matches workspace + */ + public async validateAndConsumeAuthnRequest(requestId: string, workspaceId: string): Promise { + const client = this.getClient(); + const key = `${this.AUTHN_REQUEST_PREFIX}${requestId}`; + + /** + * Get and delete atomically to prevent replay attacks + * This ensures the request can only be validated once + * Using MULTI/EXEC for atomic operation (compatible with Redis 5.0+) + */ + const results = await client + .multi() + .get(key) + .del(key) + .exec(); + + if (!results || results.length < 2) { + return false; + } + + const storedWorkspaceId = results[0] as string | null; + + if (!storedWorkspaceId) { + return false; + } + + /** + * Check workspace match + */ + return storedWorkspaceId === workspaceId; + } + + /** + * Get Redis client + * + * @returns Redis client instance + * @throws Error if Redis client is not available + */ + private getClient(): RedisClientType { + const client = this.redisHelper.getClient(); + + if (!client) { + throw new Error('Redis client is not available. Make sure Redis is initialized.'); + } + + if (!client.isOpen) { + throw new Error('Redis client is not connected. Make sure Redis connection is established.'); + } + + return client; + } +} diff --git a/src/sso/saml/storeFactory.ts b/src/sso/saml/storeFactory.ts new file mode 100644 index 00000000..1c3af998 --- /dev/null +++ b/src/sso/saml/storeFactory.ts @@ -0,0 +1,49 @@ +import RedisHelper from '../../redisHelper'; +import { MemorySamlStateStore } from './store/memory.store'; +import { RedisSamlStateStore } from './store/redis.store'; +import { SamlStateStoreInterface } from './store/SamlStateStoreInterface'; + +/** + * Create SAML state store instance based on configuration + * + * Store type is determined by SAML_STORE_TYPE environment variable: + * - 'redis' (default): Uses Redis store for multi-instance support + * - 'memory': Uses in-memory store (single instance only) + * + * @returns SAML state store instance + */ +export function createSamlStateStore(): SamlStateStoreInterface { + const storeType = (process.env.SAML_STORE_TYPE || 'redis').toLowerCase(); + + if (storeType === 'memory') { + return new MemorySamlStateStore(); + } + + if (storeType === 'redis') { + const redisHelper = RedisHelper.getInstance(); + + if (!redisHelper.isConnected()) { + console.warn( + '[SAML Store] Redis store requested but Redis is not connected. Falling back to memory store.' + ); + + return new MemorySamlStateStore(); + } + + return new RedisSamlStateStore(redisHelper); + } + + /** + * Unknown store type, default to Redis + */ + console.warn( + `[SAML Store] Unknown store type "${storeType}". Defaulting to Redis.` + ); + const redisHelper = RedisHelper.getInstance(); + + if (redisHelper.isConnected()) { + return new RedisSamlStateStore(redisHelper); + } + + return new MemorySamlStateStore(); +} diff --git a/src/types/env.d.ts b/src/types/env.d.ts index cd341491..d57b460f 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -39,6 +39,17 @@ declare namespace NodeJS { */ SSO_SP_ENTITY_ID: string; + /** + * SAML state store type + * Determines which store implementation to use for SAML authentication state + * - 'redis': Uses Redis store for multi-instance support (default) + * - 'memory': Uses in-memory store (single instance only) + * + * @default 'redis' + * @example "redis" or "memory" + */ + SAML_STORE_TYPE?: string; + /** * Redis connection URL * Used for caching and TimeSeries data From 26b9d2f73251386eb29ce0c5d8ea63c135e53b9a Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 15 Jan 2026 23:23:31 +0300 Subject: [PATCH 32/33] fix unti tests --- src/sso/saml/controller.ts | 7 +++ test/sso/saml/controller.test.ts | 63 +++++++++++++---------- test/sso/saml/store.test.ts | 86 +++++++++++++++----------------- 3 files changed, 83 insertions(+), 73 deletions(-) diff --git a/src/sso/saml/controller.ts b/src/sso/saml/controller.ts index c4cf111e..8470e014 100644 --- a/src/sso/saml/controller.ts +++ b/src/sso/saml/controller.ts @@ -413,6 +413,13 @@ export default class SamlController { * @param args - arguments to log */ private log(level: 'log' | 'warn' | 'error' | 'info' | 'success', ...args: unknown[]): void { + /** + * Disable logging in test environment + */ + if (process.env.NODE_ENV === 'test') { + return; + } + const colors = { log: Effect.ForegroundGreen, warn: Effect.ForegroundYellow, diff --git a/test/sso/saml/controller.test.ts b/test/sso/saml/controller.test.ts index 8e756b1a..d3d07706 100644 --- a/test/sso/saml/controller.test.ts +++ b/test/sso/saml/controller.test.ts @@ -6,21 +6,16 @@ import { ContextFactories } from '../../../src/types/graphql'; import { WorkspaceSsoConfig } from '../../../src/sso/types'; import { WorkspaceDBScheme, UserDBScheme } from '@hawk.so/types'; import SamlService from '../../../src/sso/saml/service'; -import samlStore from '../../../src/sso/saml/store'; +import { MemorySamlStateStore } from '../../../src/sso/saml/store/memory.store'; import * as mongo from '../../../src/mongo'; +import WorkspaceModel from '../../../src/models/workspace'; +import UserModel from '../../../src/models/user'; /** * Mock dependencies */ jest.mock('../../../src/sso/saml/service'); -/** - * Import models AFTER mongo setup to ensure databases.hawk is initialized - * This must be done after beforeAll sets up connections - */ -import WorkspaceModel from '../../../src/models/workspace'; -import UserModel from '../../../src/models/user'; - beforeAll(async () => { /** * Ensure MONGO_HAWK_DB_URL is set from MONGO_URL (set by @shelf/jest-mongodb) @@ -55,6 +50,7 @@ describe('SamlController', () => { let mockSamlService: jest.Mocked; let mockReq: Partial; let mockRes: Partial; + let samlStore: MemorySamlStateStore; const testWorkspaceId = new ObjectId().toString(); const testUserId = new ObjectId().toString(); @@ -140,6 +136,11 @@ describe('SamlController', () => { * Clear all mocks */ jest.clearAllMocks(); + + /** + * Create fresh store instance for each test + */ + samlStore = new MemorySamlStateStore(); samlStore.clear(); /** @@ -182,9 +183,9 @@ describe('SamlController', () => { (SamlService as jest.Mock).mockImplementation(() => mockSamlService); /** - * Create controller + * Create controller with store */ - controller = new SamlController(mockFactories); + controller = new SamlController(mockFactories, samlStore); /** * Mock Express Request @@ -264,7 +265,7 @@ describe('SamlController', () => { /** * Verify AuthnRequest ID was saved by checking it can be validated */ - expect(samlStore.validateAndConsumeAuthnRequest(mockRequestId, testWorkspaceId)).toBe(true); + expect(await samlStore.validateAndConsumeAuthnRequest(mockRequestId, testWorkspaceId)).toBe(true); }); it('should use default returnUrl when not provided', async () => { @@ -292,7 +293,7 @@ describe('SamlController', () => { * Verify that default returnUrl was saved in RelayState * Default returnUrl is `/workspace/${workspaceId}` */ - const relayState = samlStore.getRelayState(relayStateId!); + const relayState = await samlStore.getRelayState(relayStateId!); expect(relayState).not.toBeNull(); expect(relayState?.returnUrl).toBe(`/workspace/${testWorkspaceId}`); expect(relayState?.workspaceId).toBe(testWorkspaceId); @@ -370,7 +371,7 @@ describe('SamlController', () => { * Setup test data */ const testReturnUrl = '/workspace/test'; - const expectedFrontendUrl = `${process.env.GARAGE_URL}${testReturnUrl}`; + const expectedCallbackPath = `/login/sso/${testWorkspaceId}`; mockWorkspacesFactory.findById.mockResolvedValue(workspace); mockUsersFactory.findBySamlIdentity.mockResolvedValue(user); @@ -379,11 +380,11 @@ describe('SamlController', () => { /** * Setup samlStore to return valid state for tests */ - samlStore.saveRelayState(testRelayStateId, { + await samlStore.saveRelayState(testRelayStateId, { returnUrl: testReturnUrl, workspaceId: testWorkspaceId, }); - samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); + await samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); await controller.handleAcs(mockReq as Request, mockRes as Response); @@ -421,16 +422,18 @@ describe('SamlController', () => { expect(user.generateTokensPair).toHaveBeenCalled(); /** - * Verify redirect to frontend with returnUrl from RelayState + * Verify redirect to Garage SSO callback page with tokens and returnUrl * GARAGE_URL is set in beforeEach: 'https://garage.example.com' */ expect(mockRes.redirect).toHaveBeenCalledWith( - expect.stringContaining(expectedFrontendUrl) + expect.stringContaining(expectedCallbackPath) ); const redirectUrl = new URL((mockRes.redirect as jest.Mock).mock.calls[0][0]); + expect(redirectUrl.pathname).toBe(expectedCallbackPath); expect(redirectUrl.searchParams.get('access_token')).toBe('test-access-token'); expect(redirectUrl.searchParams.get('refresh_token')).toBe('test-refresh-token'); + expect(redirectUrl.searchParams.get('returnUrl')).toBe(testReturnUrl); }); it('should return 400 error when workspace is not found', async () => { @@ -502,11 +505,11 @@ describe('SamlController', () => { /** * Setup samlStore with valid state */ - samlStore.saveRelayState(testRelayStateId, { + await samlStore.saveRelayState(testRelayStateId, { returnUrl: '/workspace/test', workspaceId: testWorkspaceId, }); - samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); + await samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); (workspace.getMemberInfo as jest.Mock).mockResolvedValue(null); await controller.handleAcs(mockReq as Request, mockRes as Response); @@ -546,11 +549,11 @@ describe('SamlController', () => { /** * Setup samlStore with valid state */ - samlStore.saveRelayState(testRelayStateId, { + await samlStore.saveRelayState(testRelayStateId, { returnUrl: '/workspace/test', workspaceId: testWorkspaceId, }); - samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); + await samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); (workspace.getMemberInfo as jest.Mock).mockResolvedValue(null); await controller.handleAcs(mockReq as Request, mockRes as Response); @@ -582,11 +585,11 @@ describe('SamlController', () => { /** * Setup samlStore with valid state */ - samlStore.saveRelayState(testRelayStateId, { + await samlStore.saveRelayState(testRelayStateId, { returnUrl: '/workspace/test', workspaceId: testWorkspaceId, }); - samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); + await samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); (workspace.getMemberInfo as jest.Mock).mockResolvedValue({ userEmail: testEmail, }); @@ -621,17 +624,23 @@ describe('SamlController', () => { /** * Setup samlStore with AuthnRequest but no RelayState */ - samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); + await samlStore.saveAuthnRequest(testRequestId, testWorkspaceId); await controller.handleAcs(mockReq as Request, mockRes as Response); /** - * Verify redirect uses default returnUrl + * Verify redirect to Garage SSO callback page with default returnUrl */ + const expectedCallbackPath = `/login/sso/${testWorkspaceId}`; + const defaultReturnUrl = `/workspace/${testWorkspaceId}`; + expect(mockRes.redirect).toHaveBeenCalledWith( - expect.stringContaining(`/workspace/${testWorkspaceId}`) + expect.stringContaining(expectedCallbackPath) ); + + const redirectUrl = new URL((mockRes.redirect as jest.Mock).mock.calls[0][0]); + expect(redirectUrl.pathname).toBe(expectedCallbackPath); + expect(redirectUrl.searchParams.get('returnUrl')).toBe(defaultReturnUrl); }); }); }); - diff --git a/test/sso/saml/store.test.ts b/test/sso/saml/store.test.ts index 2e8df375..e648c8c7 100644 --- a/test/sso/saml/store.test.ts +++ b/test/sso/saml/store.test.ts @@ -1,23 +1,14 @@ import '../../../src/env-test'; - -/** - * Import the store class directly to create fresh instances for each test - */ -jest.isolateModules(() => { - /** - * We need to test the store module in isolation - */ -}); +import { MemorySamlStateStore } from '../../../src/sso/saml/store/memory.store'; describe('SamlStateStore', () => { - let SamlStateStore: typeof import('../../../src/sso/saml/store').default; + let SamlStateStore: MemorySamlStateStore; beforeEach(() => { /** - * Clear module cache and reimport to get fresh instance + * Create fresh instance for each test */ - jest.resetModules(); - SamlStateStore = require('../../../src/sso/saml/store').default; + SamlStateStore = new MemorySamlStateStore(); SamlStateStore.clear(); }); @@ -32,37 +23,39 @@ describe('SamlStateStore', () => { workspaceId: '507f1f77bcf86cd799439011', }; - it('should save and retrieve RelayState', () => { - SamlStateStore.saveRelayState(testStateId, testData); + it('should save and retrieve RelayState', async () => { + await SamlStateStore.saveRelayState(testStateId, testData); - const result = SamlStateStore.getRelayState(testStateId); + const result = await SamlStateStore.getRelayState(testStateId); expect(result).toEqual(testData); }); - it('should return null for non-existent RelayState', () => { - const result = SamlStateStore.getRelayState('non-existent-id'); + it('should return null for non-existent RelayState', async () => { + const result = await SamlStateStore.getRelayState('non-existent-id'); expect(result).toBeNull(); }); - it('should consume (delete) RelayState after retrieval (prevent replay)', () => { - SamlStateStore.saveRelayState(testStateId, testData); + it('should consume (delete) RelayState after retrieval (prevent replay)', async () => { + await SamlStateStore.saveRelayState(testStateId, testData); /** * First retrieval should return data */ - const firstResult = SamlStateStore.getRelayState(testStateId); + const firstResult = await SamlStateStore.getRelayState(testStateId); + expect(firstResult).toEqual(testData); /** * Second retrieval should return null (consumed) */ - const secondResult = SamlStateStore.getRelayState(testStateId); + const secondResult = await SamlStateStore.getRelayState(testStateId); + expect(secondResult).toBeNull(); }); - it('should return null for expired RelayState', () => { + it('should return null for expired RelayState', async () => { /** * Mock Date.now to simulate expiration */ @@ -70,13 +63,13 @@ describe('SamlStateStore', () => { const startTime = 1000000000000; Date.now = jest.fn().mockReturnValue(startTime); - SamlStateStore.saveRelayState(testStateId, testData); + await SamlStateStore.saveRelayState(testStateId, testData); /** * Move time forward by 6 minutes (past 5 min TTL) */ Date.now = jest.fn().mockReturnValue(startTime + 6 * 60 * 1000); - const result = SamlStateStore.getRelayState(testStateId); + const result = await SamlStateStore.getRelayState(testStateId); expect(result).toBeNull(); @@ -91,10 +84,10 @@ describe('SamlStateStore', () => { const testRequestId = '_request-id-abc123'; const testWorkspaceId = '507f1f77bcf86cd799439011'; - it('should save and validate AuthnRequest', () => { - SamlStateStore.saveAuthnRequest(testRequestId, testWorkspaceId); + it('should save and validate AuthnRequest', async () => { + await SamlStateStore.saveAuthnRequest(testRequestId, testWorkspaceId); - const result = SamlStateStore.validateAndConsumeAuthnRequest( + const result = await SamlStateStore.validateAndConsumeAuthnRequest( testRequestId, testWorkspaceId ); @@ -102,8 +95,8 @@ describe('SamlStateStore', () => { expect(result).toBe(true); }); - it('should return false for non-existent AuthnRequest', () => { - const result = SamlStateStore.validateAndConsumeAuthnRequest( + it('should return false for non-existent AuthnRequest', async () => { + const result = await SamlStateStore.validateAndConsumeAuthnRequest( 'non-existent-request', testWorkspaceId ); @@ -111,10 +104,10 @@ describe('SamlStateStore', () => { expect(result).toBe(false); }); - it('should return false for wrong workspace ID', () => { - SamlStateStore.saveAuthnRequest(testRequestId, testWorkspaceId); + it('should return false for wrong workspace ID', async () => { + await SamlStateStore.saveAuthnRequest(testRequestId, testWorkspaceId); - const result = SamlStateStore.validateAndConsumeAuthnRequest( + const result = await SamlStateStore.validateAndConsumeAuthnRequest( testRequestId, 'different-workspace-id' ); @@ -122,40 +115,42 @@ describe('SamlStateStore', () => { expect(result).toBe(false); }); - it('should consume (delete) AuthnRequest after validation (prevent replay)', () => { - SamlStateStore.saveAuthnRequest(testRequestId, testWorkspaceId); + it('should consume (delete) AuthnRequest after validation (prevent replay)', async () => { + await SamlStateStore.saveAuthnRequest(testRequestId, testWorkspaceId); /** * First validation should succeed */ - const firstResult = SamlStateStore.validateAndConsumeAuthnRequest( + const firstResult = await SamlStateStore.validateAndConsumeAuthnRequest( testRequestId, testWorkspaceId ); + expect(firstResult).toBe(true); /** * Second validation should fail (consumed) */ - const secondResult = SamlStateStore.validateAndConsumeAuthnRequest( + const secondResult = await SamlStateStore.validateAndConsumeAuthnRequest( testRequestId, testWorkspaceId ); + expect(secondResult).toBe(false); }); - it('should return false for expired AuthnRequest', () => { + it('should return false for expired AuthnRequest', async () => { const originalDateNow = Date.now; const startTime = 1000000000000; Date.now = jest.fn().mockReturnValue(startTime); - SamlStateStore.saveAuthnRequest(testRequestId, testWorkspaceId); + await SamlStateStore.saveAuthnRequest(testRequestId, testWorkspaceId); /** * Move time forward by 6 minutes (past 5 min TTL) */ Date.now = jest.fn().mockReturnValue(startTime + 6 * 60 * 1000); - const result = SamlStateStore.validateAndConsumeAuthnRequest( + const result = await SamlStateStore.validateAndConsumeAuthnRequest( testRequestId, testWorkspaceId ); @@ -167,18 +162,17 @@ describe('SamlStateStore', () => { }); describe('clear', () => { - it('should clear all stored state', () => { - SamlStateStore.saveRelayState('state-1', { + it('should clear all stored state', async () => { + await SamlStateStore.saveRelayState('state-1', { returnUrl: '/test', workspaceId: 'ws-1', }); - SamlStateStore.saveAuthnRequest('request-1', 'ws-1'); + await SamlStateStore.saveAuthnRequest('request-1', 'ws-1'); SamlStateStore.clear(); - expect(SamlStateStore.getRelayState('state-1')).toBeNull(); - expect(SamlStateStore.validateAndConsumeAuthnRequest('request-1', 'ws-1')).toBe(false); + expect(await SamlStateStore.getRelayState('state-1')).toBeNull(); + expect(await SamlStateStore.validateAndConsumeAuthnRequest('request-1', 'ws-1')).toBe(false); }); }); }); - From d95d526fbac06992fa0974fc7282e4b648ca5425 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 15 Jan 2026 23:51:33 +0300 Subject: [PATCH 33/33] fix integration tests --- docker-compose.test.yml | 6 +- docs/Keycloak.md | 212 ++++++++++++++++++ test/integration/cases/sso.test.ts | 2 +- .../keycloak/import/hawk-realm.json | 156 +++++++++++++ test/integration/keycloak/setup.sh | 129 +++++++++++ 5 files changed, 501 insertions(+), 4 deletions(-) create mode 100644 docs/Keycloak.md create mode 100644 test/integration/keycloak/import/hawk-realm.json create mode 100755 test/integration/keycloak/setup.sh diff --git a/docker-compose.test.yml b/docker-compose.test.yml index afd2b373..be1d3a69 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -11,7 +11,7 @@ services: - ./:/usr/src/app - /usr/src/app/node_modules - ./test/integration/api.env:/usr/src/app/.env - - ../keycloak:/keycloak:ro + - ./test/integration/keycloak:/keycloak:ro depends_on: - mongodb - rabbitmq @@ -47,7 +47,7 @@ services: volumes: - ./:/usr/src/app - /usr/src/app/node_modules - - ../keycloak:/keycloak:ro + - ./test/integration/keycloak:/keycloak:ro rabbitmq: image: rabbitmq:3-management @@ -80,7 +80,7 @@ services: - start-dev volumes: - keycloak-test-data:/opt/keycloak/data - - ../keycloak:/opt/keycloak/config + - ./test/integration/keycloak:/opt/keycloak/config healthcheck: test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180;echo -e 'GET /health/ready HTTP/1.1\r\nhost: http://localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"] interval: 10s diff --git a/docs/Keycloak.md b/docs/Keycloak.md new file mode 100644 index 00000000..5e8b1ae6 --- /dev/null +++ b/docs/Keycloak.md @@ -0,0 +1,212 @@ +# Keycloak for Hawk SSO Development + +This guide explains how to use Keycloak for testing Hawk's SSO implementation. + +## Quick Start + +### 1. Start Keycloak + +From the project root: + +```bash +docker-compose up keycloak +``` + +Keycloak will be available at: **http://localhost:8180** + +### 2. Run Setup Script + +The setup script will configure Keycloak with a test realm, SAML client, and test users. + +**Option 1: Run from your host machine** (recommended): + +```bash +cd api/test/integration/keycloak +KEYCLOAK_URL=http://localhost:8180 ./setup.sh +``` + +**Option 2: Run from API container** (if you don't have curl on host): + +```bash +docker-compose exec -e KEYCLOAK_URL=http://keycloak:8180 api /keycloak/setup.sh +``` + +**Note:** The setup script requires `curl` and `bash` to interact with Keycloak API. The Keycloak container doesn't have these tools, so we either run from host or from another container (like `api`). + +### 3. Access Keycloak Admin Console + +- URL: http://localhost:8180 +- Username: `admin` +- Password: `admin` + +## Configuration + +### Realm + +- **Name**: `hawk` +- **SAML Endpoint**: http://localhost:8180/realms/hawk/protocol/saml + +### SAML Client + +- **Client ID / Entity ID**: `urn:hawk:tracker:saml` + - This must match `SSO_SP_ENTITY_ID` environment variable in Hawk API +- **Protocol**: SAML 2.0 +- **ACS URL**: http://localhost:4000/auth/sso/saml/{workspaceId}/acs +- **Name ID Format**: email + +### Environment Variables + +Hawk API requires the following environment variable: + +- **SSO_SP_ENTITY_ID**: `urn:hawk:tracker:saml` + - Set in `docker-compose.yml` or `.env` file + - This is the Service Provider Entity ID used to identify Hawk in SAML requests + +### Test Users + +| Username | Email | Password | Department | Title | +|----------|-------|----------|------------|-------| +| testuser | testuser@hawk.local | password123 | Engineering | Software Engineer | +| alice | alice@hawk.local | password123 | Product | Product Manager | +| bob | bob@hawk.local | password123 | Engineering | Senior Developer | + +## Hawk SSO Configuration + +To configure SSO in Hawk workspace settings: + +### Get Configuration Automatically + +**Option 1: Use the helper script** (recommended): + +```bash +cd api/test/integration/keycloak +./get-config.sh +``` + +This will output all required values that you can copy-paste into Hawk SSO settings. + +**Option 2: Get values manually**: + +### Required Fields + +1. **IdP Entity ID**: + ``` + http://localhost:8180/realms/hawk + ``` + +2. **SSO URL**: + ``` + http://localhost:8180/realms/hawk/protocol/saml + ``` + +3. **X.509 Certificate**: + + **Via command line**: + ```bash + curl -s "http://localhost:8180/realms/hawk/protocol/saml/descriptor" | grep -oP '(?<=)[^<]+' | head -1 + ``` + + **Via Keycloak Admin Console**: + - Go to Realm Settings → Keys + - Find RS256 algorithm row + - Click "Certificate" button + - Copy the certificate (without BEGIN/END lines) + - Paste into Hawk SSO settings + +### Attribute Mapping + +Configure these mappings in Hawk: + +- **Email**: `email` +- **Name**: `name` (full name - combines firstName and lastName from Keycloak) +- **Department** (optional): `department` +- **Title** (optional): `title` + +### Name ID Format + +Select: **Email address (urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress)** + +## Testing SSO Flow + +### Manual Test + +1. Configure SSO in Hawk workspace settings with the values above +2. Enable SSO for the workspace +3. Navigate to: http://localhost:4000/auth/sso/saml/{workspaceId} +4. You'll be redirected to Keycloak login page +5. Login with any test user (e.g., `testuser@hawk.local` / `password123`) +6. After successful authentication, you'll be redirected back to Hawk with tokens + +### Automated Test + +Run integration tests: + +```bash +cd api +yarn test:integration +``` + +## Troubleshooting + +### Keycloak not starting + +Check Docker logs: +```bash +docker-compose logs keycloak +``` + +### Realm already exists + +If you need to reset: +```bash +docker-compose down -v +docker-compose up keycloak +``` + +### Certificate issues + +If SAML validation fails: +1. Verify the certificate is copied correctly (no extra spaces/newlines) +2. Ensure you copied the certificate content without BEGIN/END markers +3. Check Keycloak logs for signature errors + +### Get SAML Metadata + +You can view the full SAML metadata descriptor at: +``` +http://localhost:8180/realms/hawk/protocol/saml/descriptor +``` + +This contains all technical details about the IdP configuration. + +## Files + +Files are located in `api/test/integration/keycloak/`: + +- `import/hawk-realm.json` - Keycloak realm configuration +- `setup.sh` - Automated setup script + +## Advanced Configuration + +### Custom Workspace ID + +To test with a different workspace ID, update the ACS URL in the Keycloak Admin Console: + +1. Go to Clients → hawk-sp +2. Update `saml_assertion_consumer_url_post` attribute +3. Save changes + +### Additional Users + +You can add more users through: +- Keycloak Admin Console → Users → Add User +- Or update `api/test/integration/keycloak/import/hawk-realm.json` and re-import + +### Different Port + +If you need to run Keycloak on a different port: + +1. Update `KC_HTTP_PORT` in `docker-compose.yml` +2. Update port mapping in `docker-compose.yml` +3. Update all URLs in this README +4. Update `api/test/integration/keycloak/import/hawk-realm.json` with new URLs diff --git a/test/integration/cases/sso.test.ts b/test/integration/cases/sso.test.ts index b983aa1c..59c0e975 100644 --- a/test/integration/cases/sso.test.ts +++ b/test/integration/cases/sso.test.ts @@ -524,6 +524,6 @@ describe('SSO Integration Tests', () => { * browser automation (puppeteer/playwright) is needed * * Manual Testing: - * - See keycloak/README.md for manual testing instructions + * - See docs/Keycloak.md for manual testing instructions * - Use Keycloak admin console to view test users and SAML configuration */ diff --git a/test/integration/keycloak/import/hawk-realm.json b/test/integration/keycloak/import/hawk-realm.json new file mode 100644 index 00000000..854c02ff --- /dev/null +++ b/test/integration/keycloak/import/hawk-realm.json @@ -0,0 +1,156 @@ +{ + "realm": "hawk", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "users": [ + { + "username": "testuser", + "enabled": true, + "email": "testuser@hawk.local", + "firstName": "Test", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "password123", + "temporary": false + } + ], + "attributes": { + "department": ["Engineering"], + "title": ["Software Engineer"] + } + }, + { + "username": "alice", + "enabled": true, + "email": "alice@hawk.local", + "firstName": "Alice", + "lastName": "Johnson", + "credentials": [ + { + "type": "password", + "value": "password123", + "temporary": false + } + ], + "attributes": { + "department": ["Product"], + "title": ["Product Manager"] + } + }, + { + "username": "bob", + "enabled": true, + "email": "bob@hawk.local", + "firstName": "Bob", + "lastName": "Smith", + "credentials": [ + { + "type": "password", + "value": "password123", + "temporary": false + } + ], + "attributes": { + "department": ["Engineering"], + "title": ["Senior Developer"] + } + } + ], + "clients": [ + { + "clientId": "urn:hawk:tracker:saml", + "name": "Hawk Service Provider", + "enabled": true, + "protocol": "saml", + "frontchannelLogout": true, + "attributes": { + "saml.assertion.signature": "true", + "saml.authnstatement": "true", + "saml.client.signature": "false", + "saml.encrypt": "false", + "saml.force.post.binding": "true", + "saml.multivalued.roles": "false", + "saml.onetimeuse.condition": "false", + "saml.server.signature": "true", + "saml.server.signature.keyinfo.ext": "false", + "saml_force_name_id_format": "false", + "saml_name_id_format": "email", + "saml_signature_algorithm": "RSA_SHA256", + "saml.assertion.lifespan": "300" + }, + "defaultClientScopes": [], + "optionalClientScopes": [], + "protocolMappers": [ + { + "name": "email", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "attribute.nameformat": "Basic", + "user.attribute": "email", + "attribute.name": "email" + } + }, + { + "name": "name", + "protocol": "saml", + "protocolMapper": "saml-javascript-mapper", + "consentRequired": false, + "config": { + "attribute.nameformat": "Basic", + "attribute.name": "name", + "single": "true", + "script": "var firstName = user.getFirstName() || ''; var lastName = user.getLastName() || ''; firstName + (firstName && lastName ? ' ' : '') + lastName;" + } + }, + { + "name": "department", + "protocol": "saml", + "protocolMapper": "saml-user-attribute-mapper", + "consentRequired": false, + "config": { + "attribute.nameformat": "Basic", + "user.attribute": "department", + "attribute.name": "department" + } + }, + { + "name": "title", + "protocol": "saml", + "protocolMapper": "saml-user-attribute-mapper", + "consentRequired": false, + "config": { + "attribute.nameformat": "Basic", + "user.attribute": "title", + "attribute.name": "title" + } + } + ], + "redirectUris": [ + "http://localhost:4000/*", + "http://127.0.0.1:4000/*", + "http://localhost:8080/*", + "http://127.0.0.1:8080/*", + "http://api:4000/*" + ], + "webOrigins": [ + "http://localhost:4000", + "http://127.0.0.1:4000", + "http://localhost:8080", + "http://127.0.0.1:8080", + "http://api:4000" + ], + "adminUrl": "", + "baseUrl": "" + } + ] +} diff --git a/test/integration/keycloak/setup.sh b/test/integration/keycloak/setup.sh new file mode 100755 index 00000000..28c95092 --- /dev/null +++ b/test/integration/keycloak/setup.sh @@ -0,0 +1,129 @@ +#!/bin/bash + +# Keycloak Setup Script for Hawk SSO Development +# This script configures Keycloak with realm, client, and test users + +set -e + +KEYCLOAK_URL="${KEYCLOAK_URL:-http://localhost:8180}" +ADMIN_USER="${KEYCLOAK_ADMIN:-admin}" +ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD:-admin}" +REALM_NAME="hawk" + +# Determine the script directory and realm file path +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REALM_FILE="${SCRIPT_DIR}/import/hawk-realm.json" + +# Check if realm file exists +if [ ! -f "$REALM_FILE" ]; then + echo "❌ Realm configuration file not found: $REALM_FILE" + exit 1 +fi + +echo "🔧 Setting up Keycloak for Hawk SSO..." +echo "Keycloak URL: $KEYCLOAK_URL" + +# Wait for Keycloak to be ready +echo "⏳ Waiting for Keycloak to start..." +MAX_RETRIES=30 +RETRY_COUNT=0 + +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -s -f "$KEYCLOAK_URL/health/ready" > /dev/null 2>&1; then + echo "✓ Keycloak is ready!" + break + fi + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo "Waiting for Keycloak... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 2 +done + +if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo "❌ Keycloak failed to start in time" + exit 1 +fi + +# Get admin token +echo "🔑 Obtaining admin token..." +TOKEN_RESPONSE=$(curl -s -X POST "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=$ADMIN_USER" \ + -d "password=$ADMIN_PASSWORD" \ + -d "grant_type=password" \ + -d "client_id=admin-cli") + +ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4) + +if [ -z "$ACCESS_TOKEN" ]; then + echo "❌ Failed to obtain admin token" + echo "Response: $TOKEN_RESPONSE" + exit 1 +fi + +echo "✓ Admin token obtained" + +# Check if realm already exists +echo "🔍 Checking if realm '$REALM_NAME' exists..." +REALM_EXISTS=$(curl -s -o /dev/null -w "%{http_code}" "$KEYCLOAK_URL/admin/realms/$REALM_NAME" \ + -H "Authorization: Bearer $ACCESS_TOKEN") + +if [ "$REALM_EXISTS" = "200" ]; then + echo "⚠️ Realm '$REALM_NAME' already exists. Skipping realm creation." + echo " To reconfigure, delete the realm manually or remove Keycloak data volume." +else + echo "📦 Importing realm from configuration..." + + # Import realm + IMPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$KEYCLOAK_URL/admin/realms" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d @"$REALM_FILE") + + HTTP_CODE=$(echo "$IMPORT_RESPONSE" | tail -n1) + RESPONSE_BODY=$(echo "$IMPORT_RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" = "201" ]; then + echo "✓ Realm '$REALM_NAME' created successfully!" + else + echo "❌ Failed to create realm (HTTP $HTTP_CODE)" + echo "Response: $RESPONSE_BODY" + exit 1 + fi +fi + +# Get realm's SAML descriptor for reference +echo "📋 Fetching SAML metadata..." +SAML_DESCRIPTOR=$(curl -s "$KEYCLOAK_URL/realms/$REALM_NAME/protocol/saml/descriptor") + +if echo "$SAML_DESCRIPTOR" | grep -q "EntityDescriptor"; then + echo "✓ SAML metadata is available" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🎉 Keycloak setup completed successfully!" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "📍 Configuration Details:" + echo " Keycloak Admin Console: $KEYCLOAK_URL" + echo " Admin credentials: $ADMIN_USER / $ADMIN_PASSWORD" + echo " Realm: $REALM_NAME" + echo " Client ID: hawk-sp" + echo "" + echo "👥 Test Users:" + echo " - testuser@hawk.local / password123" + echo " - alice@hawk.local / password123" + echo " - bob@hawk.local / password123" + echo "" + echo "🔗 SSO URLs for Hawk configuration:" + echo " IdP Entity ID: $KEYCLOAK_URL/realms/$REALM_NAME" + echo " SSO URL: $KEYCLOAK_URL/realms/$REALM_NAME/protocol/saml" + echo " SAML Metadata: $KEYCLOAK_URL/realms/$REALM_NAME/protocol/saml/descriptor" + echo "" + echo "📝 Next steps:" + echo " 1. Open Hawk SSO settings in workspace" + echo " 2. Configure SSO with the URLs above" + echo " 3. Copy X.509 certificate from Keycloak admin console" + echo " (Realm Settings → Keys → RS256 → Certificate)" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +else + echo "⚠️ SAML metadata not available yet. Keycloak may still be initializing." +fi