1- import { RelayStateData , AuthnRequestState } from './types' ;
1+ import { AuthnRequestState , RelayStateData } from './types' ;
22
33/**
44 * In-memory store for SAML state
5- * @todo Replace with Redis for production
5+ *
6+ * Stores temporary data needed for SAML authentication flow:
7+ * - RelayState: maps state ID to return URL and workspace ID
8+ * - AuthnRequests: maps request ID to workspace ID for InResponseTo validation
9+ *
10+ * @todo Replace with Redis for production (multi-instance support)
611 */
712class SamlStateStore {
13+ private relayStates : Map < string , RelayStateData > = new Map ( ) ;
14+ private authnRequests : Map < string , AuthnRequestState > = new Map ( ) ;
15+
816 /**
9- * Map of relay state IDs to relay state data
17+ * Time-to-live for stored state (5 minutes)
1018 */
11- private relayStates : Map < string , RelayStateData > = new Map ( ) ;
19+ private readonly TTL = 5 * 60 * 1000 ;
1220
1321 /**
14- * Map of AuthnRequest IDs to AuthnRequest state
22+ * Interval for cleanup of expired entries (1 minute)
1523 */
16- private authnRequests : Map < string , AuthnRequestState > = new Map ( ) ;
24+ private readonly CLEANUP_INTERVAL = 60 * 1000 ;
1725
1826 /**
19- * Time-to-live for stored state (5 minutes)
27+ * Cleanup timer reference
2028 */
21- private readonly TTL = 5 * 60 * 1000 ;
29+ private cleanupTimer : NodeJS . Timeout | null = null ;
30+
31+ constructor ( ) {
32+ this . startCleanupTimer ( ) ;
33+ }
2234
2335 /**
24- * Save relay state
36+ * Save RelayState data
37+ *
38+ * @param stateId - unique state identifier (usually UUID)
39+ * @param data - relay state data (returnUrl, workspaceId)
2540 */
2641 public saveRelayState ( stateId : string , data : { returnUrl : string ; workspaceId : string } ) : void {
2742 this . relayStates . set ( stateId , {
@@ -31,7 +46,10 @@ class SamlStateStore {
3146 }
3247
3348 /**
34- * Get relay state by ID
49+ * Get and consume RelayState data
50+ *
51+ * @param stateId - state identifier
52+ * @returns relay state data or null if not found/expired
3553 */
3654 public getRelayState ( stateId : string ) : { returnUrl : string ; workspaceId : string } | null {
3755 const state = this . relayStates . get ( stateId ) ;
@@ -40,16 +58,28 @@ class SamlStateStore {
4058 return null ;
4159 }
4260
61+ /**
62+ * Check expiration
63+ */
4364 if ( Date . now ( ) > state . expiresAt ) {
4465 this . relayStates . delete ( stateId ) ;
66+
4567 return null ;
4668 }
4769
70+ /**
71+ * Consume (delete after use to prevent replay)
72+ */
73+ this . relayStates . delete ( stateId ) ;
74+
4875 return { returnUrl : state . returnUrl , workspaceId : state . workspaceId } ;
4976 }
5077
5178 /**
52- * Save AuthnRequest state
79+ * Save AuthnRequest for InResponseTo validation
80+ *
81+ * @param requestId - SAML AuthnRequest ID
82+ * @param workspaceId - workspace ID
5383 */
5484 public saveAuthnRequest ( requestId : string , workspaceId : string ) : void {
5585 this . authnRequests . set ( requestId , {
@@ -60,58 +90,102 @@ class SamlStateStore {
6090
6191 /**
6292 * Validate and consume AuthnRequest
63- * Returns true if request is valid and not expired, false otherwise
64- * Removes the request from storage after validation
93+ *
94+ * @param requestId - SAML AuthnRequest ID (from InResponseTo)
95+ * @param workspaceId - expected workspace ID
96+ * @returns true if request is valid and matches workspace
6597 */
6698 public validateAndConsumeAuthnRequest ( requestId : string , workspaceId : string ) : boolean {
67- const state = this . authnRequests . get ( requestId ) ;
99+ const request = this . authnRequests . get ( requestId ) ;
68100
69- if ( ! state ) {
101+ if ( ! request ) {
70102 return false ;
71103 }
72104
73- if ( Date . now ( ) > state . expiresAt ) {
105+ /**
106+ * Check expiration
107+ */
108+ if ( Date . now ( ) > request . expiresAt ) {
74109 this . authnRequests . delete ( requestId ) ;
110+
75111 return false ;
76112 }
77113
78- if ( state . workspaceId !== workspaceId ) {
79- this . authnRequests . delete ( requestId ) ;
114+ /**
115+ * Check workspace match
116+ */
117+ if ( request . workspaceId !== workspaceId ) {
80118 return false ;
81119 }
82120
83121 /**
84- * Remove request after successful validation ( prevent replay attacks)
122+ * Consume (delete after use to prevent replay attacks)
85123 */
86124 this . authnRequests . delete ( requestId ) ;
125+
87126 return true ;
88127 }
89128
90129 /**
91- * Clean up expired entries (can be called periodically)
130+ * Start periodic cleanup of expired entries
92131 */
93- public cleanup ( ) : void {
94- const now = Date . now ( ) ;
95-
132+ private startCleanupTimer ( ) : void {
96133 /**
97- * Clean up expired relay states
134+ * Don't start timer in test environment
98135 */
99- for ( const [ id , state ] of this . relayStates . entries ( ) ) {
100- if ( now > state . expiresAt ) {
101- this . relayStates . delete ( id ) ;
102- }
136+ if ( process . env . NODE_ENV === 'test' ) {
137+ return ;
103138 }
104139
140+ this . cleanupTimer = setInterval ( ( ) => {
141+ this . cleanup ( ) ;
142+ } , this . CLEANUP_INTERVAL ) ;
143+
105144 /**
106- * Clean up expired AuthnRequests
145+ * Don't prevent process from exiting
107146 */
108- for ( const [ id , state ] of this . authnRequests . entries ( ) ) {
109- if ( now > state . expiresAt ) {
110- this . authnRequests . delete ( id ) ;
147+ this . cleanupTimer . unref ( ) ;
148+ }
149+
150+ /**
151+ * Clean up expired entries
152+ */
153+ private cleanup ( ) : void {
154+ const now = Date . now ( ) ;
155+
156+ for ( const [ key , value ] of this . relayStates ) {
157+ if ( now > value . expiresAt ) {
158+ this . relayStates . delete ( key ) ;
159+ }
160+ }
161+
162+ for ( const [ key , value ] of this . authnRequests ) {
163+ if ( now > value . expiresAt ) {
164+ this . authnRequests . delete ( key ) ;
111165 }
112166 }
113167 }
168+
169+ /**
170+ * Stop cleanup timer (for testing)
171+ */
172+ public stopCleanupTimer ( ) : void {
173+ if ( this . cleanupTimer ) {
174+ clearInterval ( this . cleanupTimer ) ;
175+ this . cleanupTimer = null ;
176+ }
177+ }
178+
179+ /**
180+ * Clear all stored state (for testing)
181+ */
182+ public clear ( ) : void {
183+ this . relayStates . clear ( ) ;
184+ this . authnRequests . clear ( ) ;
185+ }
114186}
115187
188+ /**
189+ * Singleton instance
190+ */
116191export default new SamlStateStore ( ) ;
117-
0 commit comments