Skip to content

Commit 1963a59

Browse files
committed
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.
1 parent 0725454 commit 1963a59

File tree

6 files changed

+862
-5
lines changed

6 files changed

+862
-5
lines changed

jest.config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ module.exports = {
2424
* TypeScript support
2525
*/
2626
transform: {
27-
'^.+\\.tsx?$': 'ts-jest',
27+
'^.+\\.tsx?$': ['ts-jest', {
28+
tsconfig: 'test/tsconfig.json',
29+
}],
2830
},
2931

3032
/**

src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { metricsMiddleware, createMetricsServer, graphqlMetricsPlugin } from './
3030
import { requestLogger } from './utils/logger';
3131
import ReleasesFactory from './models/releasesFactory';
3232
import RedisHelper from './redisHelper';
33+
import { appendSsoRoutes } from './sso';
3334

3435
/**
3536
* Option to enable playground
@@ -246,6 +247,22 @@ class HawkAPI {
246247

247248
await redis.initialize();
248249

250+
/**
251+
* Setup shared factories for SSO routes
252+
* SSO endpoints don't require per-request DataLoaders isolation,
253+
* so we can reuse the same factories instance
254+
* Created here to avoid duplication with createContext
255+
*/
256+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
257+
const ssoDataLoaders = new DataLoaders(mongo.databases.hawk!);
258+
const ssoFactories = HawkAPI.setupFactories(ssoDataLoaders);
259+
260+
/**
261+
* Append SSO routes to Express app using shared factories
262+
* Note: This must be called after database connections are established
263+
*/
264+
appendSsoRoutes(this.app, ssoFactories);
265+
249266
await this.server.start();
250267
this.app.use(graphqlUploadExpress());
251268
this.server.applyMiddleware({ app: this.app });

src/sso/saml/controller.ts

Lines changed: 188 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import express from 'express';
2+
import { v4 as uuid } from 'uuid';
23
import SamlService from './service';
34
import samlStore from './store';
45
import { ContextFactories } from '../../types/graphql';
6+
import { SamlResponseData } from '../types';
7+
import WorkspaceModel from '../../models/workspace';
8+
import UserModel from '../../models/user';
59

610
/**
711
* Controller for SAML SSO endpoints
@@ -37,20 +41,200 @@ export default class SamlController {
3741
* Initiate SSO login (GET /auth/sso/saml/:workspaceId)
3842
*/
3943
public async initiateLogin(req: express.Request, res: express.Response): Promise<void> {
44+
const { workspaceId } = req.params;
45+
const returnUrl = (req.query.returnUrl as string) || `/workspace/${workspaceId}`;
46+
47+
/**
48+
* 1. Check if workspace has SSO enabled
49+
*/
50+
const workspace = await this.factories.workspacesFactory.findById(workspaceId);
51+
52+
if (!workspace || !workspace.sso?.enabled) {
53+
res.status(400).json({ error: 'SSO is not enabled for this workspace' });
54+
return;
55+
}
56+
57+
/**
58+
* 2. Compose Assertion Consumer Service URL
59+
*/
60+
const acsUrl = this.getAcsUrl(workspaceId);
61+
const relayStateId = uuid();
62+
4063
/**
41-
* TODO: Implement according to specification
64+
* 3. Save RelayState to temporary storage
4265
*/
43-
throw new Error('Not implemented');
66+
samlStore.saveRelayState(relayStateId, { returnUrl, workspaceId });
67+
68+
/**
69+
* 4. Generate AuthnRequest
70+
*/
71+
const { requestId, encodedRequest } = await this.samlService.generateAuthnRequest(
72+
workspaceId,
73+
acsUrl,
74+
relayStateId,
75+
workspace.sso.saml
76+
);
77+
78+
/**
79+
* 5. Save AuthnRequest ID for InResponseTo validation
80+
*/
81+
samlStore.saveAuthnRequest(requestId, workspaceId);
82+
83+
/**
84+
* 6. Redirect to IdP
85+
*/
86+
const redirectUrl = new URL(workspace.sso.saml.ssoUrl);
87+
redirectUrl.searchParams.set('SAMLRequest', encodedRequest);
88+
redirectUrl.searchParams.set('RelayState', relayStateId);
89+
90+
res.redirect(redirectUrl.toString());
4491
}
4592

4693
/**
4794
* Handle ACS callback (POST /auth/sso/saml/:workspaceId/acs)
4895
*/
4996
public async handleAcs(req: express.Request, res: express.Response): Promise<void> {
97+
const { workspaceId } = req.params;
98+
const samlResponse = req.body.SAMLResponse as string;
99+
const relayStateId = req.body.RelayState as string;
100+
101+
/**
102+
* 1. Get workspace SSO configuration and check if SSO is enabled
103+
*/
104+
const workspace = await this.factories.workspacesFactory.findById(workspaceId);
105+
106+
if (!workspace || !workspace.sso?.enabled) {
107+
res.status(400).json({ error: 'SSO is not enabled' });
108+
return;
109+
}
110+
111+
/**
112+
* 2. Validate and parse SAML Response
113+
*/
114+
const acsUrl = this.getAcsUrl(workspaceId);
115+
116+
let samlData: SamlResponseData;
117+
118+
try {
119+
/**
120+
* Validate and parse SAML Response
121+
* Note: InResponseTo validation is done separately after parsing
122+
*/
123+
samlData = await this.samlService.validateAndParseResponse(
124+
samlResponse,
125+
workspaceId,
126+
acsUrl,
127+
workspace.sso.saml
128+
);
129+
130+
/**
131+
* Validate InResponseTo against stored AuthnRequest
132+
*/
133+
if (samlData.inResponseTo) {
134+
const isValidRequest = samlStore.validateAndConsumeAuthnRequest(
135+
samlData.inResponseTo,
136+
workspaceId
137+
);
138+
139+
if (!isValidRequest) {
140+
res.status(400).json({ error: 'Invalid SAML response: InResponseTo validation failed' });
141+
return;
142+
}
143+
}
144+
} catch (error) {
145+
console.error('SAML validation error:', {
146+
workspaceId,
147+
error: error instanceof Error ? error.message : 'Unknown error',
148+
});
149+
res.status(400).json({ error: 'Invalid SAML response' });
150+
return;
151+
}
152+
153+
/**
154+
* 3. Find or create user
155+
*/
156+
let user = await this.factories.usersFactory.findBySamlIdentity(workspaceId, samlData.nameId);
157+
158+
if (!user) {
159+
/**
160+
* JIT provisioning or invite-only policy
161+
*/
162+
user = await this.handleUserProvisioning(workspaceId, samlData, workspace);
163+
}
164+
165+
/**
166+
* 4. Get RelayState for return URL (before consuming)
167+
* Note: RelayState is consumed after first use, so we need to get it before validation
168+
*/
169+
const relayState = samlStore.getRelayState(relayStateId);
170+
const finalReturnUrl = relayState?.returnUrl || `/workspace/${workspaceId}`;
171+
50172
/**
51-
* TODO: Implement according to specification
173+
* 5. Create Hawk session
52174
*/
53-
throw new Error('Not implemented');
175+
const tokens = await user.generateTokensPair();
176+
177+
/**
178+
* 6. Redirect to Garage with tokens
179+
*/
180+
const frontendUrl = new URL(finalReturnUrl, process.env.GARAGE_URL || 'http://localhost:3000');
181+
frontendUrl.searchParams.set('access_token', tokens.accessToken);
182+
frontendUrl.searchParams.set('refresh_token', tokens.refreshToken);
183+
184+
res.redirect(frontendUrl.toString());
185+
}
186+
187+
/**
188+
* Handle user provisioning (JIT or invite-only)
189+
*
190+
* @param workspaceId - workspace ID
191+
* @param samlData - parsed SAML response data
192+
* @param workspace - workspace model
193+
* @returns UserModel instance
194+
*/
195+
private async handleUserProvisioning(
196+
workspaceId: string,
197+
samlData: SamlResponseData,
198+
workspace: WorkspaceModel
199+
): Promise<UserModel> {
200+
/**
201+
* Find user by email
202+
*/
203+
let user = await this.factories.usersFactory.findByEmail(samlData.email);
204+
205+
if (!user) {
206+
/**
207+
* Create new user (JIT provisioning)
208+
* Password is not set - only SSO login is allowed
209+
*/
210+
user = await this.factories.usersFactory.create(samlData.email, undefined, undefined);
211+
}
212+
213+
/**
214+
* Link SAML identity to user
215+
*/
216+
await user.linkSamlIdentity(workspaceId, samlData.nameId, samlData.email);
217+
218+
/**
219+
* Check if user is a member of the workspace
220+
*/
221+
const member = await workspace.getMemberInfo(user._id.toString());
222+
223+
if (!member) {
224+
/**
225+
* Add user to workspace (JIT provisioning)
226+
*/
227+
await workspace.addMember(user._id.toString());
228+
await user.addWorkspace(workspaceId);
229+
} else if (WorkspaceModel.isPendingMember(member)) {
230+
/**
231+
* Confirm pending membership
232+
*/
233+
await workspace.confirmMembership(user);
234+
await user.confirmMembership(workspaceId);
235+
}
236+
237+
return user;
54238
}
55239
}
56240

src/sso/saml/service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export default class SamlService {
1010
/**
1111
* Generate SAML AuthnRequest
1212
*
13+
* AuthnRequest - a SAML-message that Hawk sends to IdP to initiate auth process.
14+
*
1315
* @param workspaceId - workspace ID
1416
* @param acsUrl - Assertion Consumer Service URL
1517
* @param relayState - context of user returning (url + relay state id)

0 commit comments

Comments
 (0)