11import express from 'express' ;
22import { v4 as uuid } from 'uuid' ;
3+ import { ObjectId } from 'mongodb' ;
34import SamlService from './service' ;
45import samlStore from './store' ;
56import { ContextFactories } from '../../types/graphql' ;
@@ -26,6 +27,16 @@ export default class SamlController {
2627 this . factories = factories ;
2728 }
2829
30+ /**
31+ * Validate workspace ID format
32+ *
33+ * @param workspaceId - workspace ID to validate
34+ * @returns true if valid, false otherwise
35+ */
36+ private isValidWorkspaceId ( workspaceId : string ) : boolean {
37+ return ObjectId . isValid ( workspaceId ) ;
38+ }
39+
2940 /**
3041 * Compose Assertion Consumer Service URL for workspace
3142 *
@@ -41,147 +52,199 @@ export default class SamlController {
4152 * Initiate SSO login (GET /auth/sso/saml/:workspaceId)
4253 */
4354 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 } ` ;
55+ try {
56+ const { workspaceId } = req . params ;
57+ const returnUrl = ( req . query . returnUrl as string ) || `/workspace/${ workspaceId } ` ;
4658
47- /**
48- * 1. Check if workspace has SSO enabled
49- */
50- const workspace = await this . factories . workspacesFactory . findById ( workspaceId ) ;
59+ /**
60+ * Validate workspace ID format
61+ */
62+ if ( ! this . isValidWorkspaceId ( workspaceId ) ) {
63+ res . status ( 400 ) . json ( { error : 'Invalid workspace ID' } ) ;
64+ return ;
65+ }
5166
52- if ( ! workspace || ! workspace . sso ?. enabled ) {
53- res . status ( 400 ) . json ( { error : 'SSO is not enabled for this workspace' } ) ;
54- return ;
55- }
67+ /**
68+ * 1. Check if workspace has SSO enabled
69+ */
70+ const workspace = await this . factories . workspacesFactory . findById ( workspaceId ) ;
5671
57- /**
58- * 2. Compose Assertion Consumer Service URL
59- */
60- const acsUrl = this . getAcsUrl ( workspaceId ) ;
61- const relayStateId = uuid ( ) ;
72+ if ( ! workspace || ! workspace . sso ?. enabled ) {
73+ res . status ( 400 ) . json ( { error : 'SSO is not enabled for this workspace' } ) ;
74+ return ;
75+ }
6276
63- /**
64- * 3. Save RelayState to temporary storage
65- */
66- samlStore . saveRelayState ( relayStateId , { returnUrl, workspaceId } ) ;
77+ /**
78+ * 2. Compose Assertion Consumer Service URL
79+ */
80+ const acsUrl = this . getAcsUrl ( workspaceId ) ;
81+ const relayStateId = uuid ( ) ;
6782
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- ) ;
83+ /**
84+ * 3. Save RelayState to temporary storage
85+ */
86+ samlStore . saveRelayState ( relayStateId , { returnUrl, workspaceId } ) ;
7787
78- /**
79- * 5. Save AuthnRequest ID for InResponseTo validation
80- */
81- samlStore . saveAuthnRequest ( requestId , workspaceId ) ;
88+ /**
89+ * 4. Generate AuthnRequest
90+ */
91+ const { requestId, encodedRequest } = await this . samlService . generateAuthnRequest (
92+ workspaceId ,
93+ acsUrl ,
94+ relayStateId ,
95+ workspace . sso . saml
96+ ) ;
8297
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 ) ;
98+ /**
99+ * 5. Save AuthnRequest ID for InResponseTo validation
100+ */
101+ samlStore . saveAuthnRequest ( requestId , workspaceId ) ;
102+
103+ /**
104+ * 6. Redirect to IdP
105+ */
106+ const redirectUrl = new URL ( workspace . sso . saml . ssoUrl ) ;
107+ redirectUrl . searchParams . set ( 'SAMLRequest' , encodedRequest ) ;
108+ redirectUrl . searchParams . set ( 'RelayState' , relayStateId ) ;
89109
90- res . redirect ( redirectUrl . toString ( ) ) ;
110+ res . redirect ( redirectUrl . toString ( ) ) ;
111+ } catch ( error ) {
112+ console . error ( 'SSO initiation error:' , {
113+ workspaceId : req . params . workspaceId ,
114+ error : error instanceof Error ? error . message : 'Unknown error' ,
115+ } ) ;
116+ res . status ( 500 ) . json ( { error : 'Failed to initiate SSO login' } ) ;
117+ }
91118 }
92119
93120 /**
94121 * Handle ACS callback (POST /auth/sso/saml/:workspaceId/acs)
95122 */
96123 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- }
124+ try {
125+ const { workspaceId } = req . params ;
126+ const samlResponse = req . body . SAMLResponse as string ;
127+ const relayStateId = req . body . RelayState as string ;
110128
111- /**
112- * 2. Validate and parse SAML Response
113- */
114- const acsUrl = this . getAcsUrl ( workspaceId ) ;
129+ /**
130+ * Validate workspace ID format
131+ */
132+ if ( ! this . isValidWorkspaceId ( workspaceId ) ) {
133+ res . status ( 400 ) . json ( { error : 'Invalid workspace ID' } ) ;
134+ return ;
135+ }
115136
116- let samlData : SamlResponseData ;
137+ /**
138+ * Validate required SAML response
139+ */
140+ if ( ! samlResponse ) {
141+ res . status ( 400 ) . json ( { error : 'SAML response is required' } ) ;
142+ return ;
143+ }
117144
118- try {
119145 /**
120- * Validate and parse SAML Response
121- * Note: InResponseTo validation is done separately after parsing
146+ * 1. Get workspace SSO configuration and check if SSO is enabled
122147 */
123- samlData = await this . samlService . validateAndParseResponse (
124- samlResponse ,
125- workspaceId ,
126- acsUrl ,
127- workspace . sso . saml
128- ) ;
148+ const workspace = await this . factories . workspacesFactory . findById ( workspaceId ) ;
149+
150+ if ( ! workspace || ! workspace . sso ?. enabled ) {
151+ res . status ( 400 ) . json ( { error : 'SSO is not enabled for this workspace' } ) ;
152+ return ;
153+ }
129154
130155 /**
131- * Validate InResponseTo against stored AuthnRequest
156+ * 2. Validate and parse SAML Response
132157 */
133- if ( samlData . inResponseTo ) {
134- const isValidRequest = samlStore . validateAndConsumeAuthnRequest (
135- samlData . inResponseTo ,
136- workspaceId
158+ const acsUrl = this . getAcsUrl ( workspaceId ) ;
159+
160+ let samlData : SamlResponseData ;
161+
162+ try {
163+ /**
164+ * Validate and parse SAML Response
165+ * Note: InResponseTo validation is done separately after parsing
166+ */
167+ samlData = await this . samlService . validateAndParseResponse (
168+ samlResponse ,
169+ workspaceId ,
170+ acsUrl ,
171+ workspace . sso . saml
137172 ) ;
138173
139- if ( ! isValidRequest ) {
140- res . status ( 400 ) . json ( { error : 'Invalid SAML response: InResponseTo validation failed' } ) ;
141- return ;
174+ /**
175+ * Validate InResponseTo against stored AuthnRequest
176+ */
177+ if ( samlData . inResponseTo ) {
178+ const isValidRequest = samlStore . validateAndConsumeAuthnRequest (
179+ samlData . inResponseTo ,
180+ workspaceId
181+ ) ;
182+
183+ if ( ! isValidRequest ) {
184+ res . status ( 400 ) . json ( { error : 'Invalid SAML response: InResponseTo validation failed' } ) ;
185+ return ;
186+ }
142187 }
188+ } catch ( error ) {
189+ console . error ( 'SAML validation error:' , {
190+ workspaceId,
191+ error : error instanceof Error ? error . message : 'Unknown error' ,
192+ } ) ;
193+ res . status ( 400 ) . json ( { error : 'Invalid SAML response' } ) ;
194+ return ;
143195 }
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- }
152196
153- /**
154- * 3. Find or create user
155- */
156- let user = await this . factories . usersFactory . findBySamlIdentity ( workspaceId , samlData . nameId ) ;
197+ /**
198+ * 3. Find or create user
199+ */
200+ let user = await this . factories . usersFactory . findBySamlIdentity ( workspaceId , samlData . nameId ) ;
201+
202+ if ( ! user ) {
203+ /**
204+ * JIT provisioning or invite-only policy
205+ */
206+ user = await this . handleUserProvisioning ( workspaceId , samlData , workspace ) ;
207+ }
157208
158- if ( ! user ) {
159209 /**
160- * JIT provisioning or invite-only policy
210+ * 4. Get RelayState for return URL (before consuming)
211+ * Note: RelayState is consumed after first use, so we need to get it before validation
161212 */
162- user = await this . handleUserProvisioning ( workspaceId , samlData , workspace ) ;
163- }
213+ const relayState = samlStore . getRelayState ( relayStateId ) ;
214+ const finalReturnUrl = relayState ?. returnUrl || `/workspace/ ${ workspaceId } ` ;
164215
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 } ` ;
216+ /**
217+ * 5. Create Hawk session
218+ */
219+ const tokens = await user . generateTokensPair ( ) ;
171220
172- /**
173- * 5. Create Hawk session
174- */
175- const tokens = await user . generateTokensPair ( ) ;
221+ /**
222+ * 6. Redirect to Garage with tokens
223+ */
224+ const frontendUrl = new URL ( finalReturnUrl , process . env . GARAGE_URL || 'http://localhost:3000' ) ;
225+ frontendUrl . searchParams . set ( 'access_token' , tokens . accessToken ) ;
226+ frontendUrl . searchParams . set ( 'refresh_token' , tokens . refreshToken ) ;
176227
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 ) ;
228+ res . redirect ( frontendUrl . toString ( ) ) ;
229+ } catch ( error ) {
230+ /**
231+ * Handle specific error types
232+ */
233+ if ( error instanceof Error && error . message . includes ( 'SAML' ) ) {
234+ console . error ( 'SAML processing error:' , {
235+ workspaceId : req . params . workspaceId ,
236+ error : error . message ,
237+ } ) ;
238+ res . status ( 400 ) . json ( { error : 'Invalid SAML response' } ) ;
239+ return ;
240+ }
183241
184- res . redirect ( frontendUrl . toString ( ) ) ;
242+ console . error ( 'ACS callback error:' , {
243+ workspaceId : req . params . workspaceId ,
244+ error : error instanceof Error ? error . message : 'Unknown error' ,
245+ } ) ;
246+ res . status ( 500 ) . json ( { error : 'Failed to process SSO callback' } ) ;
247+ }
185248 }
186249
187250 /**
0 commit comments