Skip to content

Commit 61fea8e

Browse files
committed
Refactor SAML response validation to use node-saml
Replaces custom SAML assertion validation logic with @node-saml/node-saml for signature, audience, and time validation. Updates error handling to map node-saml errors to SamlValidationError types, adds fallback error type, and removes now-unnecessary utility functions and tests. Extends and improves test coverage for SAML response parsing, error cases, and attribute extraction.
1 parent 77d55e9 commit 61fea8e

File tree

5 files changed

+461
-208
lines changed

5 files changed

+461
-208
lines changed

src/sso/saml/service.ts

Lines changed: 158 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { SAML, SamlConfig as NodeSamlConfig, Profile } from '@node-saml/node-saml';
12
import { SamlConfig, SamlResponseData } from '../types';
23
import { SamlValidationError, SamlValidationErrorType } from './types';
3-
import { validateAudience, validateRecipient, validateTimeConditions } from './utils';
4+
import { extractAttribute } from './utils';
45

56
/**
67
* Service for SAML SSO operations
@@ -40,31 +41,172 @@ export default class SamlService {
4041
* @param workspaceId - workspace ID
4142
* @param acsUrl - expected Assertion Consumer Service URL
4243
* @param samlConfig - SAML configuration
44+
* @param expectedRequestId - optional expected InResponseTo value (if provided, validates that response matches)
4345
* @returns parsed SAML response data
46+
* @throws SamlValidationError if validation fails
4447
*/
4548
public async validateAndParseResponse(
4649
samlResponse: string,
4750
workspaceId: string,
4851
acsUrl: string,
49-
samlConfig: SamlConfig
52+
samlConfig: SamlConfig,
53+
expectedRequestId?: string
5054
): Promise<SamlResponseData> {
55+
const saml = this.createSamlInstance(acsUrl, samlConfig);
56+
57+
let profile: Profile;
58+
59+
try {
60+
/**
61+
* node-saml validates:
62+
* - XML signature using x509Cert
63+
* - Audience (via idpIssuer option)
64+
* - Time conditions (NotBefore, NotOnOrAfter with clock skew)
65+
*/
66+
const result = await saml.validatePostResponseAsync({
67+
SAMLResponse: samlResponse,
68+
});
69+
70+
if (!result.profile) {
71+
throw new SamlValidationError(
72+
SamlValidationErrorType.INVALID_SIGNATURE,
73+
'SAML response validation failed: no profile returned'
74+
);
75+
}
76+
77+
profile = result.profile;
78+
} catch (error) {
79+
const message = error instanceof Error ? error.message : 'Unknown SAML validation error';
80+
81+
/**
82+
* Determine specific error type based on error message
83+
*/
84+
if (message.includes('signature')) {
85+
throw new SamlValidationError(
86+
SamlValidationErrorType.INVALID_SIGNATURE,
87+
`SAML signature validation failed: ${message}`
88+
);
89+
}
90+
91+
if (message.includes('expired') || message.includes('NotOnOrAfter') || message.includes('NotBefore')) {
92+
throw new SamlValidationError(
93+
SamlValidationErrorType.EXPIRED_ASSERTION,
94+
`SAML assertion time validation failed: ${message}`
95+
);
96+
}
97+
98+
if (message.includes('audience') || message.includes('Audience')) {
99+
throw new SamlValidationError(
100+
SamlValidationErrorType.INVALID_AUDIENCE,
101+
`SAML audience validation failed: ${message}`
102+
);
103+
}
104+
105+
/**
106+
* Fallback for unknown error types
107+
* Note: Error classification relies on message text which may change between library versions
108+
*/
109+
throw new SamlValidationError(
110+
SamlValidationErrorType.VALIDATION_FAILED,
111+
`SAML validation failed: ${message}`
112+
);
113+
}
114+
51115
/**
52-
* @todo Implement using @node-saml/node-saml
53-
*
54-
* This method should:
55-
* 1. Decode base64 SAML Response
56-
* 2. Validate XML signature using x509Cert
57-
* 3. Validate Audience (should match SSO_SP_ENTITY_ID)
58-
* 4. Validate Recipient (should match acsUrl)
59-
* 5. Validate InResponseTo (should match saved AuthnRequest ID)
60-
* 6. Validate time conditions (NotBefore, NotOnOrAfter)
61-
* 7. Extract NameID
62-
* 8. Extract email using attributeMapping
63-
* 9. Extract name using attributeMapping (if available)
64-
* 10. Return parsed data
116+
* Extract NameID (Profile type defines nameID as required string)
65117
*/
66-
throw new Error('Not implemented');
118+
const nameId = profile.nameID;
119+
120+
if (!nameId) {
121+
throw new SamlValidationError(
122+
SamlValidationErrorType.INVALID_NAME_ID,
123+
'SAML response does not contain NameID'
124+
);
125+
}
126+
127+
/**
128+
* Extract InResponseTo and validate if expectedRequestId provided
129+
* Profile uses index signature [attributeName: string]: unknown for additional properties
130+
*/
131+
const inResponseTo = profile.inResponseTo as string | undefined;
132+
133+
if (expectedRequestId && inResponseTo !== expectedRequestId) {
134+
throw new SamlValidationError(
135+
SamlValidationErrorType.INVALID_IN_RESPONSE_TO,
136+
`InResponseTo mismatch: expected ${expectedRequestId}, got ${inResponseTo}`,
137+
{ expected: expectedRequestId, received: inResponseTo }
138+
);
139+
}
140+
141+
/**
142+
* Extract attributes from profile
143+
* node-saml puts SAML attributes directly on the profile object via index signature
144+
*/
145+
const attributes = profile as unknown as Record<string, string | string[]>;
146+
147+
/**
148+
* Extract email using attributeMapping
149+
*/
150+
const email = extractAttribute(attributes, samlConfig.attributeMapping.email);
151+
152+
if (!email) {
153+
throw new SamlValidationError(
154+
SamlValidationErrorType.MISSING_EMAIL,
155+
`Email attribute not found in SAML response. Expected attribute: ${samlConfig.attributeMapping.email}`,
156+
{ attributeMapping: samlConfig.attributeMapping }
157+
);
158+
}
159+
160+
/**
161+
* Extract name using attributeMapping (optional)
162+
*/
163+
let name: string | undefined;
164+
165+
if (samlConfig.attributeMapping.name) {
166+
name = extractAttribute(attributes, samlConfig.attributeMapping.name);
167+
}
168+
169+
return {
170+
nameId,
171+
email,
172+
name,
173+
inResponseTo,
174+
};
67175
}
68176

177+
/**
178+
* Create node-saml SAML instance with given configuration
179+
*
180+
* @param acsUrl - Assertion Consumer Service URL
181+
* @param samlConfig - SAML configuration from workspace
182+
* @returns configured SAML instance
183+
*/
184+
private createSamlInstance(acsUrl: string, samlConfig: SamlConfig): SAML {
185+
const spEntityId = process.env.SSO_SP_ENTITY_ID;
186+
187+
if (!spEntityId) {
188+
throw new Error('SSO_SP_ENTITY_ID environment variable is not set');
189+
}
190+
191+
const options: NodeSamlConfig = {
192+
callbackUrl: acsUrl,
193+
entryPoint: samlConfig.ssoUrl,
194+
issuer: spEntityId,
195+
idpIssuer: samlConfig.idpEntityId,
196+
idpCert: samlConfig.x509Cert,
197+
wantAssertionsSigned: true,
198+
wantAuthnResponseSigned: false,
199+
/**
200+
* Allow 2 minutes clock skew for time validation
201+
*/
202+
acceptedClockSkewMs: 2 * 60 * 1000,
203+
};
204+
205+
if (samlConfig.nameIdFormat) {
206+
options.identifierFormat = samlConfig.nameIdFormat;
207+
}
208+
209+
return new SAML(options);
210+
}
69211
}
70212

src/sso/saml/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export enum SamlValidationErrorType {
1414
EXPIRED_ASSERTION = 'EXPIRED_ASSERTION',
1515
INVALID_NAME_ID = 'INVALID_NAME_ID',
1616
MISSING_EMAIL = 'MISSING_EMAIL',
17+
/**
18+
* Generic validation error when specific type cannot be determined
19+
* Used as fallback when library error messages don't match known patterns
20+
*/
21+
VALIDATION_FAILED = 'VALIDATION_FAILED',
1722
}
1823

1924
/**

src/sso/saml/utils.ts

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -23,61 +23,3 @@ export function extractAttribute(attributes: Record<string, string | string[]>,
2323
return undefined;
2424
}
2525

26-
/**
27-
* Validate PEM certificate format
28-
*
29-
* @param cert - certificate string
30-
* @returns true if certificate appears to be valid PEM format
31-
*/
32-
export function isValidPemCertificate(cert: string): boolean {
33-
return cert.includes('-----BEGIN CERTIFICATE-----') && cert.includes('-----END CERTIFICATE-----');
34-
}
35-
36-
/**
37-
* Validate Audience value
38-
*
39-
* @param audience - audience value from SAML Assertion
40-
* @returns true if audience matches SSO_SP_ENTITY_ID
41-
* @throws Error if SSO_SP_ENTITY_ID environment variable is not set
42-
*/
43-
export function validateAudience(audience: string): boolean {
44-
const spEntityId = process.env.SSO_SP_ENTITY_ID;
45-
46-
if (!spEntityId) {
47-
throw new Error('SSO_SP_ENTITY_ID environment variable is not set');
48-
}
49-
50-
return audience === spEntityId;
51-
}
52-
53-
/**
54-
* Validate Recipient value
55-
*
56-
* @param recipient - recipient URL from SAML Assertion
57-
* @param expectedAcsUrl - expected ACS URL
58-
* @returns true if recipient matches expected ACS URL
59-
*/
60-
export function validateRecipient(recipient: string, expectedAcsUrl: string): boolean {
61-
return recipient === expectedAcsUrl;
62-
}
63-
64-
/**
65-
* Validate time conditions (NotBefore and NotOnOrAfter)
66-
*
67-
* @param notBefore - NotBefore timestamp
68-
* @param notOnOrAfter - NotOnOrAfter timestamp
69-
* @param clockSkew - allowed clock skew in milliseconds (default: 2 minutes)
70-
* @returns true if assertion is valid at current time
71-
*/
72-
export function validateTimeConditions(
73-
notBefore: Date,
74-
notOnOrAfter: Date,
75-
clockSkew: number = 2 * 60 * 1000
76-
): boolean {
77-
const now = Date.now();
78-
const notBeforeTime = notBefore.getTime() - clockSkew;
79-
const notOnOrAfterTime = notOnOrAfter.getTime() + clockSkew;
80-
81-
return now >= notBeforeTime && now < notOnOrAfterTime;
82-
}
83-

0 commit comments

Comments
 (0)