Skip to content

Commit 3ecdd48

Browse files
committed
bootstrap module
1 parent 4ef3df4 commit 3ecdd48

File tree

8 files changed

+567
-0
lines changed

8 files changed

+567
-0
lines changed

src/sso/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import express from 'express';
2+
import { createSamlRouter } from './saml';
3+
import { ContextFactories } from '../types/graphql';
4+
5+
/**
6+
* Append SSO routes to Express app
7+
*
8+
* @param app - Express application instance
9+
* @param factories - context factories for database access
10+
*/
11+
export function appendSsoRoutes(app: express.Application, factories: ContextFactories): void {
12+
const samlRouter = createSamlRouter(factories);
13+
app.use('/auth/sso/saml', samlRouter);
14+
}
15+

src/sso/saml/controller.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import express from 'express';
2+
import SamlService from './service';
3+
import samlStore from './store';
4+
import { ContextFactories } from '../../types/graphql';
5+
6+
/**
7+
* Controller for SAML SSO endpoints
8+
*/
9+
export default class SamlController {
10+
/**
11+
* SAML service instance
12+
*/
13+
private samlService: SamlService;
14+
15+
/**
16+
* Context factories for database access
17+
*/
18+
private factories: ContextFactories;
19+
20+
constructor(factories: ContextFactories) {
21+
this.samlService = new SamlService();
22+
this.factories = factories;
23+
}
24+
25+
/**
26+
* Compose Assertion Consumer Service URL for workspace
27+
*
28+
* @param workspaceId - workspace ID
29+
* @returns ACS URL
30+
*/
31+
private getAcsUrl(workspaceId: string): string {
32+
const apiUrl = process.env.API_URL || 'https://api.hawk.so';
33+
return `${apiUrl}/auth/sso/saml/${workspaceId}/acs`;
34+
}
35+
36+
/**
37+
* Initiate SSO login (GET /auth/sso/saml/:workspaceId)
38+
*/
39+
public async initiateLogin(req: express.Request, res: express.Response): Promise<void> {
40+
/**
41+
* TODO: Implement according to specification
42+
*/
43+
throw new Error('Not implemented');
44+
}
45+
46+
/**
47+
* Handle ACS callback (POST /auth/sso/saml/:workspaceId/acs)
48+
*/
49+
public async handleAcs(req: express.Request, res: express.Response): Promise<void> {
50+
/**
51+
* TODO: Implement according to specification
52+
*/
53+
throw new Error('Not implemented');
54+
}
55+
}
56+

src/sso/saml/index.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import express from 'express';
2+
import SamlController from './controller';
3+
import { ContextFactories } from '../../types/graphql';
4+
5+
/**
6+
* Create SAML router
7+
*
8+
* @param factories - context factories for database access
9+
* @returns Express router with SAML endpoints
10+
*/
11+
export function createSamlRouter(factories: ContextFactories): express.Router {
12+
const router = express.Router();
13+
const controller = new SamlController(factories);
14+
15+
/**
16+
* SSO login initiation
17+
* GET /auth/sso/saml/:workspaceId
18+
*/
19+
router.get('/:workspaceId', async (req, res, next) => {
20+
try {
21+
await controller.initiateLogin(req, res);
22+
} catch (error) {
23+
next(error);
24+
}
25+
});
26+
27+
/**
28+
* ACS callback
29+
* POST /auth/sso/saml/:workspaceId/acs
30+
*/
31+
router.post('/:workspaceId/acs', async (req, res, next) => {
32+
try {
33+
await controller.handleAcs(req, res);
34+
} catch (error) {
35+
next(error);
36+
}
37+
});
38+
39+
return router;
40+
}
41+

src/sso/saml/service.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { SamlConfig, SamlResponseData } from '../types';
2+
import { SamlValidationError, SamlValidationErrorType } from './types';
3+
4+
/**
5+
* Service for SAML SSO operations
6+
*/
7+
export default class SamlService {
8+
/**
9+
* Generate SAML AuthnRequest
10+
*
11+
* @param workspaceId - workspace ID
12+
* @param acsUrl - Assertion Consumer Service URL
13+
* @param relayState - relay state to pass through
14+
* @param samlConfig - SAML configuration
15+
* @returns AuthnRequest ID and encoded SAML request
16+
*/
17+
public async generateAuthnRequest(
18+
workspaceId: string,
19+
acsUrl: string,
20+
relayState: string,
21+
samlConfig: SamlConfig
22+
): Promise<{ requestId: string; encodedRequest: string }> {
23+
/**
24+
* @todo Implement using @node-saml/node-saml
25+
*
26+
* This method should:
27+
* 1. Generate unique AuthnRequest ID
28+
* 2. Create SAML AuthnRequest XML
29+
* 3. Encode it as base64
30+
* 4. Return both requestId and encoded request
31+
*/
32+
throw new Error('Not implemented');
33+
}
34+
35+
/**
36+
* Validate and parse SAML Response
37+
*
38+
* @param samlResponse - base64-encoded SAML Response
39+
* @param workspaceId - workspace ID
40+
* @param acsUrl - expected Assertion Consumer Service URL
41+
* @param samlConfig - SAML configuration
42+
* @returns parsed SAML response data
43+
*/
44+
public async validateAndParseResponse(
45+
samlResponse: string,
46+
workspaceId: string,
47+
acsUrl: string,
48+
samlConfig: SamlConfig
49+
): Promise<SamlResponseData> {
50+
/**
51+
* @todo Implement using @node-saml/node-saml
52+
*
53+
* This method should:
54+
* 1. Decode base64 SAML Response
55+
* 2. Validate XML signature using x509Cert
56+
* 3. Validate Audience (should match SSO_SP_ENTITY_ID)
57+
* 4. Validate Recipient (should match acsUrl)
58+
* 5. Validate InResponseTo (should match saved AuthnRequest ID)
59+
* 6. Validate time conditions (NotBefore, NotOnOrAfter)
60+
* 7. Extract NameID
61+
* 8. Extract email using attributeMapping
62+
* 9. Extract name using attributeMapping (if available)
63+
* 10. Return parsed data
64+
*/
65+
throw new Error('Not implemented');
66+
}
67+
68+
/**
69+
* Validate Audience value
70+
*
71+
* @param audience - audience value from SAML Assertion
72+
* @returns true if audience matches SSO_SP_ENTITY_ID
73+
*/
74+
public validateAudience(audience: string): boolean {
75+
const spEntityId = process.env.SSO_SP_ENTITY_ID;
76+
77+
if (!spEntityId) {
78+
throw new Error('SSO_SP_ENTITY_ID environment variable is not set');
79+
}
80+
81+
return audience === spEntityId;
82+
}
83+
84+
/**
85+
* Validate Recipient value
86+
*
87+
* @param recipient - recipient URL from SAML Assertion
88+
* @param expectedAcsUrl - expected ACS URL
89+
* @returns true if recipient matches expected ACS URL
90+
*/
91+
public validateRecipient(recipient: string, expectedAcsUrl: string): boolean {
92+
return recipient === expectedAcsUrl;
93+
}
94+
95+
/**
96+
* Validate time conditions (NotBefore and NotOnOrAfter)
97+
*
98+
* @param notBefore - NotBefore timestamp
99+
* @param notOnOrAfter - NotOnOrAfter timestamp
100+
* @param clockSkew - allowed clock skew in milliseconds (default: 2 minutes)
101+
* @returns true if assertion is valid at current time
102+
*/
103+
public validateTimeConditions(
104+
notBefore: Date,
105+
notOnOrAfter: Date,
106+
clockSkew: number = 2 * 60 * 1000
107+
): boolean {
108+
const now = Date.now();
109+
const notBeforeTime = notBefore.getTime() - clockSkew;
110+
const notOnOrAfterTime = notOnOrAfter.getTime() + clockSkew;
111+
112+
return now >= notBeforeTime && now < notOnOrAfterTime;
113+
}
114+
}
115+

src/sso/saml/store.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { RelayStateData, AuthnRequestState } from './types';
2+
3+
/**
4+
* In-memory store for SAML state
5+
* @todo Replace with Redis for production
6+
*/
7+
class SamlStateStore {
8+
/**
9+
* Map of relay state IDs to relay state data
10+
*/
11+
private relayStates: Map<string, RelayStateData> = new Map();
12+
13+
/**
14+
* Map of AuthnRequest IDs to AuthnRequest state
15+
*/
16+
private authnRequests: Map<string, AuthnRequestState> = new Map();
17+
18+
/**
19+
* Time-to-live for stored state (5 minutes)
20+
*/
21+
private readonly TTL = 5 * 60 * 1000;
22+
23+
/**
24+
* Save relay state
25+
*/
26+
public saveRelayState(stateId: string, data: { returnUrl: string; workspaceId: string }): void {
27+
this.relayStates.set(stateId, {
28+
...data,
29+
expiresAt: Date.now() + this.TTL,
30+
});
31+
}
32+
33+
/**
34+
* Get relay state by ID
35+
*/
36+
public getRelayState(stateId: string): { returnUrl: string; workspaceId: string } | null {
37+
const state = this.relayStates.get(stateId);
38+
39+
if (!state) {
40+
return null;
41+
}
42+
43+
if (Date.now() > state.expiresAt) {
44+
this.relayStates.delete(stateId);
45+
return null;
46+
}
47+
48+
return { returnUrl: state.returnUrl, workspaceId: state.workspaceId };
49+
}
50+
51+
/**
52+
* Save AuthnRequest state
53+
*/
54+
public saveAuthnRequest(requestId: string, workspaceId: string): void {
55+
this.authnRequests.set(requestId, {
56+
workspaceId,
57+
expiresAt: Date.now() + this.TTL,
58+
});
59+
}
60+
61+
/**
62+
* Validate and consume AuthnRequest
63+
* Returns true if request is valid and not expired, false otherwise
64+
* Removes the request from storage after validation
65+
*/
66+
public validateAndConsumeAuthnRequest(requestId: string, workspaceId: string): boolean {
67+
const state = this.authnRequests.get(requestId);
68+
69+
if (!state) {
70+
return false;
71+
}
72+
73+
if (Date.now() > state.expiresAt) {
74+
this.authnRequests.delete(requestId);
75+
return false;
76+
}
77+
78+
if (state.workspaceId !== workspaceId) {
79+
this.authnRequests.delete(requestId);
80+
return false;
81+
}
82+
83+
/**
84+
* Remove request after successful validation (prevent replay attacks)
85+
*/
86+
this.authnRequests.delete(requestId);
87+
return true;
88+
}
89+
90+
/**
91+
* Clean up expired entries (can be called periodically)
92+
*/
93+
public cleanup(): void {
94+
const now = Date.now();
95+
96+
/**
97+
* Clean up expired relay states
98+
*/
99+
for (const [id, state] of this.relayStates.entries()) {
100+
if (now > state.expiresAt) {
101+
this.relayStates.delete(id);
102+
}
103+
}
104+
105+
/**
106+
* Clean up expired AuthnRequests
107+
*/
108+
for (const [id, state] of this.authnRequests.entries()) {
109+
if (now > state.expiresAt) {
110+
this.authnRequests.delete(id);
111+
}
112+
}
113+
}
114+
}
115+
116+
export default new SamlStateStore();
117+

0 commit comments

Comments
 (0)