Skip to content

Commit 1369b2c

Browse files
committed
Implement SAML AuthnRequest generation and tests
Added logic to generate SAML AuthnRequest using node-saml, extract the request ID from the encoded request, and handle errors. Updated and expanded unit tests to cover successful generation, error cases, and correct invocation of SAML library methods.
1 parent 61fea8e commit 1369b2c

File tree

2 files changed

+154
-15
lines changed

2 files changed

+154
-15
lines changed

src/sso/saml/service.ts

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default class SamlService {
1212
*
1313
* @param workspaceId - workspace ID
1414
* @param acsUrl - Assertion Consumer Service URL
15-
* @param relayState - relay state to pass through
15+
* @param relayState - context of user returning (url + relay state id)
1616
* @param samlConfig - SAML configuration
1717
* @returns AuthnRequest ID and encoded SAML request
1818
*/
@@ -22,16 +22,59 @@ export default class SamlService {
2222
relayState: string,
2323
samlConfig: SamlConfig
2424
): Promise<{ requestId: string; encodedRequest: string }> {
25+
const saml = this.createSamlInstance(acsUrl, samlConfig);
26+
2527
/**
26-
* @todo Implement using @node-saml/node-saml
27-
*
28-
* This method should:
29-
* 1. Generate unique AuthnRequest ID
30-
* 2. Create SAML AuthnRequest XML
31-
* 3. Encode it as base64
32-
* 4. Return both requestId and encoded request
28+
* Generate AuthnRequest message
29+
* node-saml returns object with SAMLRequest (deflated + base64 encoded)
3330
*/
34-
throw new Error('Not implemented');
31+
const authorizeMessage = await saml.getAuthorizeMessageAsync(relayState, undefined, {});
32+
33+
const encodedRequest = authorizeMessage.SAMLRequest as string;
34+
35+
if (!encodedRequest) {
36+
throw new Error('Failed to generate SAML AuthnRequest');
37+
}
38+
39+
/**
40+
* Extract request ID from the generated request
41+
* node-saml generates unique ID internally using generateUniqueId option
42+
* We need to decode and parse to get the ID for InResponseTo validation
43+
*/
44+
const requestId = this.extractRequestIdFromEncodedRequest(encodedRequest);
45+
46+
return {
47+
requestId,
48+
encodedRequest,
49+
};
50+
}
51+
52+
/**
53+
* Extract request ID from encoded SAML AuthnRequest
54+
*
55+
* @param encodedRequest - deflated and base64 encoded SAML request
56+
* @returns request ID
57+
*/
58+
private extractRequestIdFromEncodedRequest(encodedRequest: string): string {
59+
const zlib = require('zlib');
60+
61+
/**
62+
* Decode base64 and inflate
63+
*/
64+
const decoded = Buffer.from(encodedRequest, 'base64');
65+
const inflated = zlib.inflateRawSync(decoded).toString('utf-8');
66+
67+
/**
68+
* Extract ID attribute from AuthnRequest XML
69+
* Format: <samlp:AuthnRequest ... ID="_abc123" ...>
70+
*/
71+
const idMatch = inflated.match(/ID="([^"]+)"/);
72+
73+
if (!idMatch || !idMatch[1]) {
74+
throw new Error('Failed to extract request ID from AuthnRequest');
75+
}
76+
77+
return idMatch[1];
3578
}
3679

3780
/**

test/sso/saml/service.test.ts

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ describe('SamlService', () => {
3030

3131
const mockSamlInstance = {
3232
validatePostResponseAsync: jest.fn(),
33+
getAuthorizeMessageAsync: jest.fn(),
3334
};
3435

3536
beforeEach(() => {
@@ -47,14 +48,109 @@ describe('SamlService', () => {
4748
});
4849

4950
describe('generateAuthnRequest', () => {
51+
const testRelayState = 'test-relay-state-123';
52+
5053
/**
51-
* TODO: Add tests for:
52-
* 1. Should generate valid AuthnRequest with correct structure
53-
* 2. Should include correct ACS URL
54-
* 3. Should include correct SP Entity ID
55-
* 4. Should return unique request ID
56-
* 5. Should return base64-encoded request
54+
* Helper to create a mock SAML AuthnRequest (deflated + base64 encoded)
5755
*/
56+
function createMockEncodedRequest(requestId: string): string {
57+
const zlib = require('zlib');
58+
const xml = `<?xml version="1.0"?>
59+
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
60+
ID="${requestId}"
61+
Version="2.0"
62+
IssueInstant="2025-01-01T00:00:00Z"
63+
Destination="https://idp.example.com/sso"
64+
AssertionConsumerServiceURL="https://api.example.com/auth/sso/saml/507f1f77bcf86cd799439011/acs">
65+
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">urn:hawk:tracker:saml</saml:Issuer>
66+
</samlp:AuthnRequest>`;
67+
68+
const deflated = zlib.deflateRawSync(xml);
69+
70+
return deflated.toString('base64');
71+
}
72+
73+
it('should generate AuthnRequest and return request ID', async () => {
74+
const mockRequestId = '_test-request-id-12345';
75+
const mockEncodedRequest = createMockEncodedRequest(mockRequestId);
76+
77+
mockSamlInstance.getAuthorizeMessageAsync.mockResolvedValue({
78+
SAMLRequest: mockEncodedRequest,
79+
RelayState: testRelayState,
80+
});
81+
82+
const result = await samlService.generateAuthnRequest(
83+
testWorkspaceId,
84+
testAcsUrl,
85+
testRelayState,
86+
testSamlConfig
87+
);
88+
89+
expect(result.requestId).toBe(mockRequestId);
90+
expect(result.encodedRequest).toBe(mockEncodedRequest);
91+
});
92+
93+
it('should call getAuthorizeMessageAsync with correct relay state', async () => {
94+
const mockRequestId = '_another-request-id';
95+
const mockEncodedRequest = createMockEncodedRequest(mockRequestId);
96+
97+
mockSamlInstance.getAuthorizeMessageAsync.mockResolvedValue({
98+
SAMLRequest: mockEncodedRequest,
99+
});
100+
101+
await samlService.generateAuthnRequest(
102+
testWorkspaceId,
103+
testAcsUrl,
104+
testRelayState,
105+
testSamlConfig
106+
);
107+
108+
expect(mockSamlInstance.getAuthorizeMessageAsync).toHaveBeenCalledWith(
109+
testRelayState,
110+
undefined,
111+
{}
112+
);
113+
});
114+
115+
it('should throw error when SAMLRequest is not returned', async () => {
116+
mockSamlInstance.getAuthorizeMessageAsync.mockResolvedValue({
117+
/**
118+
* No SAMLRequest in response
119+
*/
120+
});
121+
122+
await expect(
123+
samlService.generateAuthnRequest(
124+
testWorkspaceId,
125+
testAcsUrl,
126+
testRelayState,
127+
testSamlConfig
128+
)
129+
).rejects.toThrow('Failed to generate SAML AuthnRequest');
130+
});
131+
132+
it('should throw error when request ID cannot be extracted', async () => {
133+
const zlib = require('zlib');
134+
/**
135+
* Invalid XML without ID attribute
136+
*/
137+
const invalidXml = '<invalid>no id here</invalid>';
138+
const deflated = zlib.deflateRawSync(invalidXml);
139+
const invalidEncodedRequest = deflated.toString('base64');
140+
141+
mockSamlInstance.getAuthorizeMessageAsync.mockResolvedValue({
142+
SAMLRequest: invalidEncodedRequest,
143+
});
144+
145+
await expect(
146+
samlService.generateAuthnRequest(
147+
testWorkspaceId,
148+
testAcsUrl,
149+
testRelayState,
150+
testSamlConfig
151+
)
152+
).rejects.toThrow('Failed to extract request ID from AuthnRequest');
153+
});
58154
});
59155

60156
describe('validateAndParseResponse', () => {

0 commit comments

Comments
 (0)