Skip to content

Commit 0725454

Browse files
committed
SamlStateStore implemetation
1 parent 1369b2c commit 0725454

File tree

2 files changed

+291
-33
lines changed

2 files changed

+291
-33
lines changed

src/sso/saml/store.ts

Lines changed: 107 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,42 @@
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
*/
712
class 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+
*/
116191
export default new SamlStateStore();
117-

0 commit comments

Comments
 (0)