Skip to content

Commit de08345

Browse files
committed
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.
1 parent 73a89e6 commit de08345

File tree

10 files changed

+407
-19
lines changed

10 files changed

+407
-19
lines changed

.env.sample

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,6 @@ AWS_S3_BUCKET_ENDPOINT=
9090
# SSO Service Provider Entity ID
9191
# Unique identifier for Hawk in SAML IdP configuration
9292
SSO_SP_ENTITY_ID=urn:hawk:tracker:saml
93+
94+
## SAML state store type (memory or redis, default: redis)
95+
SAML_STORE_TYPE=redis

.env.test

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,6 @@ AWS_S3_SECRET_ACCESS_KEY=
9797
AWS_S3_BUCKET_NAME=
9898
AWS_S3_BUCKET_BASE_URL=
9999
AWS_S3_BUCKET_ENDPOINT=
100+
101+
## SAML state store type (memory or redis, default: redis)
102+
SAML_STORE_TYPE=memory

src/redisHelper.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,15 @@ export default class RedisHelper {
139139
return Boolean(this.redisClient?.isOpen);
140140
}
141141

142+
/**
143+
* Get Redis client instance
144+
*
145+
* @returns Redis client or null if not initialized
146+
*/
147+
public getClient(): RedisClientType | null {
148+
return this.redisClient;
149+
}
150+
142151
/**
143152
* Execute TS.RANGE command with aggregation
144153
*

src/sso/saml/controller.ts

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import express from 'express';
22
import { v4 as uuid } from 'uuid';
33
import { ObjectId } from 'mongodb';
44
import SamlService from './service';
5-
import samlStore from './store';
5+
import { SamlStateStoreInterface } from './store/SamlStateStoreInterface';
66
import { ContextFactories } from '../../types/graphql';
77
import { SamlResponseData } from '../types';
88
import WorkspaceModel from '../../models/workspace';
@@ -23,13 +23,21 @@ export default class SamlController {
2323
*/
2424
private factories: ContextFactories;
2525

26+
/**
27+
* SAML state store instance
28+
*/
29+
private store: SamlStateStoreInterface;
30+
2631
/**
2732
* SAML controller constructor used for DI
33+
*
2834
* @param factories - for working with models
35+
* @param store - SAML state store instance
2936
*/
30-
constructor(factories: ContextFactories) {
37+
constructor(factories: ContextFactories, store: SamlStateStoreInterface) {
3138
this.samlService = new SamlService();
3239
this.factories = factories;
40+
this.store = store;
3341
}
3442

3543
/**
@@ -74,10 +82,20 @@ export default class SamlController {
7482
/**
7583
* 3. Save RelayState to temporary storage
7684
*/
77-
samlStore.saveRelayState(relayStateId, {
85+
this.log(
86+
'info',
87+
'[Store] Saving RelayState:',
88+
sgr(relayStateId.slice(0, 8), Effect.ForegroundGray),
89+
'| Store:',
90+
sgr(this.store.type, Effect.ForegroundBlue),
91+
'| Workspace:',
92+
sgr(workspaceId, Effect.ForegroundCyan)
93+
);
94+
await this.store.saveRelayState(relayStateId, {
7895
returnUrl,
7996
workspaceId,
8097
});
98+
this.log('log', '[Store] RelayState saved:', sgr(relayStateId.slice(0, 8), Effect.ForegroundGray));
8199

82100
/**
83101
* 4. Generate AuthnRequest
@@ -105,7 +123,17 @@ export default class SamlController {
105123
/**
106124
* 5. Save AuthnRequest ID for InResponseTo validation
107125
*/
108-
samlStore.saveAuthnRequest(requestId, workspaceId);
126+
this.log(
127+
'info',
128+
'[Store] Saving AuthnRequest:',
129+
sgr(requestId.slice(0, 8), Effect.ForegroundGray),
130+
'| Store:',
131+
sgr(this.store.type, Effect.ForegroundBlue),
132+
'| Workspace:',
133+
sgr(workspaceId, Effect.ForegroundCyan)
134+
);
135+
await this.store.saveAuthnRequest(requestId, workspaceId);
136+
this.log('log', '[Store] AuthnRequest saved:', sgr(requestId.slice(0, 8), Effect.ForegroundGray));
109137

110138
/**
111139
* 6. Redirect to IdP
@@ -212,11 +240,34 @@ export default class SamlController {
212240
* Validate InResponseTo against stored AuthnRequest
213241
*/
214242
if (samlData.inResponseTo) {
215-
const isValidRequest = samlStore.validateAndConsumeAuthnRequest(
243+
this.log(
244+
'info',
245+
'[Store] Validating AuthnRequest:',
246+
sgr(samlData.inResponseTo.slice(0, 8), Effect.ForegroundGray),
247+
'| Store:',
248+
sgr(this.store.type, Effect.ForegroundBlue),
249+
'| Workspace:',
250+
sgr(workspaceId, Effect.ForegroundCyan)
251+
);
252+
const isValidRequest = await this.store.validateAndConsumeAuthnRequest(
216253
samlData.inResponseTo,
217254
workspaceId
218255
);
219256

257+
if (isValidRequest) {
258+
this.log(
259+
'log',
260+
'[Store] AuthnRequest validated and consumed:',
261+
sgr(samlData.inResponseTo.slice(0, 8), Effect.ForegroundGray)
262+
);
263+
} else {
264+
this.log(
265+
'warn',
266+
'[Store] AuthnRequest validation failed:',
267+
sgr(samlData.inResponseTo.slice(0, 8), Effect.ForegroundRed)
268+
);
269+
}
270+
220271
if (!isValidRequest) {
221272
this.log(
222273
'error',
@@ -274,7 +325,27 @@ export default class SamlController {
274325
* 4. Get RelayState for return URL (before consuming)
275326
* Note: RelayState is consumed after first use, so we need to get it before validation
276327
*/
277-
const relayState = samlStore.getRelayState(relayStateId);
328+
this.log(
329+
'info',
330+
'[Store] Getting RelayState:',
331+
sgr(relayStateId.slice(0, 8), Effect.ForegroundGray),
332+
'| Store:',
333+
sgr(this.store.type, Effect.ForegroundBlue)
334+
);
335+
const relayState = await this.store.getRelayState(relayStateId);
336+
337+
if (relayState) {
338+
this.log(
339+
'log',
340+
'[Store] RelayState retrieved and consumed:',
341+
sgr(relayStateId.slice(0, 8), Effect.ForegroundGray),
342+
'| Return URL:',
343+
sgr(relayState.returnUrl, Effect.ForegroundGray)
344+
);
345+
} else {
346+
this.log('warn', '[Store] RelayState not found or expired:', sgr(relayStateId.slice(0, 8), Effect.ForegroundRed));
347+
}
348+
278349
const finalReturnUrl = relayState?.returnUrl || `/workspace/${workspaceId}`;
279350

280351
/**

src/sso/saml/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import express from 'express';
22
import SamlController from './controller';
3+
import { createSamlStateStore } from './storeFactory';
34
import { ContextFactories } from '../../types/graphql';
45

56
/**
@@ -10,7 +11,8 @@ import { ContextFactories } from '../../types/graphql';
1011
*/
1112
export function createSamlRouter(factories: ContextFactories): express.Router {
1213
const router = express.Router();
13-
const controller = new SamlController(factories);
14+
const store = createSamlStateStore();
15+
const controller = new SamlController(factories, store);
1416

1517
/**
1618
* SSO login initiation
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Interface for SAML state store implementations
3+
*
4+
* Defines contract for storing temporary SAML authentication state:
5+
* - RelayState: maps state ID to return URL and workspace ID
6+
* - AuthnRequests: maps request ID to workspace ID for InResponseTo validation
7+
*/
8+
export interface SamlStateStoreInterface {
9+
/**
10+
* Store type identifier
11+
* Used for logging and debugging purposes
12+
*
13+
* @example "redis" or "memory"
14+
*/
15+
readonly type: string;
16+
17+
/**
18+
* Save RelayState data
19+
*
20+
* @param stateId - unique state identifier (usually UUID)
21+
* @param data - relay state data (returnUrl, workspaceId)
22+
*/
23+
saveRelayState(stateId: string, data: { returnUrl: string; workspaceId: string }): Promise<void>;
24+
25+
/**
26+
* Get and consume RelayState data
27+
*
28+
* @param stateId - state identifier
29+
* @returns relay state data or null if not found/expired
30+
*/
31+
getRelayState(stateId: string): Promise<{ returnUrl: string; workspaceId: string } | null>;
32+
33+
/**
34+
* Save AuthnRequest for InResponseTo validation
35+
*
36+
* @param requestId - SAML AuthnRequest ID
37+
* @param workspaceId - workspace ID
38+
*/
39+
saveAuthnRequest(requestId: string, workspaceId: string): Promise<void>;
40+
41+
/**
42+
* Validate and consume AuthnRequest
43+
*
44+
* @param requestId - SAML AuthnRequest ID (from InResponseTo)
45+
* @param workspaceId - expected workspace ID
46+
* @returns true if request is valid and matches workspace
47+
*/
48+
validateAndConsumeAuthnRequest(requestId: string, workspaceId: string): Promise<boolean>;
49+
50+
/**
51+
* Stop cleanup timer (for testing)
52+
* Optional method - only needed for in-memory store
53+
*/
54+
stopCleanupTimer?(): void;
55+
56+
/**
57+
* Clear all stored state (for testing)
58+
* Optional method - only needed for in-memory store
59+
*/
60+
clear?(): void;
61+
}
Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { AuthnRequestState, RelayStateData } from './types';
1+
import { AuthnRequestState, RelayStateData } from '../types';
2+
import { SamlStateStoreInterface } from './SamlStateStoreInterface';
23

34
/**
45
* In-memory store for SAML state
@@ -7,9 +8,15 @@ import { AuthnRequestState, RelayStateData } from './types';
78
* - RelayState: maps state ID to return URL and workspace ID
89
* - AuthnRequests: maps request ID to workspace ID for InResponseTo validation
910
*
10-
* @todo Replace with Redis for production (multi-instance support)
11+
* Note: This implementation is not suitable for multi-instance deployments.
12+
* Use Redis store for production environments with multiple API instances.
1113
*/
12-
class SamlStateStore {
14+
export class MemorySamlStateStore implements SamlStateStoreInterface {
15+
/**
16+
* Store type identifier
17+
*/
18+
public readonly type = 'memory';
19+
1320
private relayStates: Map<string, RelayStateData> = new Map();
1421
private authnRequests: Map<string, AuthnRequestState> = new Map();
1522

@@ -41,7 +48,7 @@ class SamlStateStore {
4148
* @param stateId - unique state identifier (usually UUID)
4249
* @param data - relay state data (returnUrl, workspaceId)
4350
*/
44-
public saveRelayState(stateId: string, data: { returnUrl: string; workspaceId: string }): void {
51+
public async saveRelayState(stateId: string, data: { returnUrl: string; workspaceId: string }): Promise<void> {
4552
this.relayStates.set(stateId, {
4653
...data,
4754
expiresAt: Date.now() + this.TTL,
@@ -54,7 +61,7 @@ class SamlStateStore {
5461
* @param stateId - state identifier
5562
* @returns relay state data or null if not found/expired
5663
*/
57-
public getRelayState(stateId: string): { returnUrl: string; workspaceId: string } | null {
64+
public async getRelayState(stateId: string): Promise<{ returnUrl: string; workspaceId: string } | null> {
5865
const state = this.relayStates.get(stateId);
5966

6067
if (!state) {
@@ -87,7 +94,7 @@ class SamlStateStore {
8794
* @param requestId - SAML AuthnRequest ID
8895
* @param workspaceId - workspace ID
8996
*/
90-
public saveAuthnRequest(requestId: string, workspaceId: string): void {
97+
public async saveAuthnRequest(requestId: string, workspaceId: string): Promise<void> {
9198
this.authnRequests.set(requestId, {
9299
workspaceId,
93100
expiresAt: Date.now() + this.TTL,
@@ -101,7 +108,7 @@ class SamlStateStore {
101108
* @param workspaceId - expected workspace ID
102109
* @returns true if request is valid and matches workspace
103110
*/
104-
public validateAndConsumeAuthnRequest(requestId: string, workspaceId: string): boolean {
111+
public async validateAndConsumeAuthnRequest(requestId: string, workspaceId: string): Promise<boolean> {
105112
const request = this.authnRequests.get(requestId);
106113

107114
if (!request) {
@@ -190,8 +197,3 @@ class SamlStateStore {
190197
}
191198
}
192199
}
193-
194-
/**
195-
* Singleton instance
196-
*/
197-
export default new SamlStateStore();

0 commit comments

Comments
 (0)