Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import { parseMetadata, createIdPMetadataXML, createSPMetadataXML } from './meta
import { createPostForm } from './post';
import { sign } from './sign';
import { decryptXml } from './decrypt';
import { parseLogoutResponse, createLogoutRequest } from './logout';
import { parseLogoutResponse, createLogoutRequest, parseLogoutRequest, createLogoutResponse } from './logout';
import type {
ParsedLogoutResponse,
LogoutRequestParams,
ParsedLogoutRequest,
LogoutResponseParams,
} from './logout';

export default {
parseMetadata,
Expand All @@ -32,4 +38,8 @@ export default {
WrapError,
parseLogoutResponse,
createLogoutRequest,
parseLogoutRequest,
createLogoutResponse,
};

export type { ParsedLogoutResponse, LogoutRequestParams, ParsedLogoutRequest, LogoutResponseParams };
167 changes: 155 additions & 12 deletions lib/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,26 @@ import crypto from 'crypto';
import xml2js from 'xml2js';
import xmlbuilder from 'xmlbuilder';

const parseLogoutResponse = async (
rawResponse: string
): Promise<{
type ParsedLogoutResponse = {
id: string;
issuer: string;
status: string;
destination: string;
inResponseTo: string;
}> => {
};

type LogoutRequestParams = {
nameId: string;
providerName: string;
sloUrl: string;
};

const parseLogoutResponse = async (rawResponse: string): Promise<ParsedLogoutResponse> => {
return new Promise((resolve, reject) => {
xml2js.parseString(
rawResponse,
{ tagNameProcessors: [xml2js.processors.stripPrefix] },
(err: Error | null, parsedData: { LogoutResponse: any }) => {
(err: Error | null, parsedData) => {
if (err) {
reject(err);
return;
Expand All @@ -38,14 +44,10 @@ const createLogoutRequest = ({
nameId,
providerName,
sloUrl,
}: {
nameId: string;
providerName: string;
sloUrl: string;
}): { id: string; xml: string } => {
}: LogoutRequestParams): { id: string; xml: string } => {
const id = '_' + crypto.randomBytes(10).toString('hex');

const xml: Record<string, any> = {
const xml = {
'samlp:LogoutRequest': {
'@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
'@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
Expand All @@ -69,4 +71,145 @@ const createLogoutRequest = ({
};
};

export { parseLogoutResponse, createLogoutRequest };
type ParsedLogoutRequest = {
id: string;
issuer: string;
nameId: string;
sessionIndex?: string;
destination?: string;
publicKey?: string;
idToken?: string;
};

type LogoutResponseParams = {
requestId: string;
issuer: string;
destination: string;
status?: string;
};

const parseLogoutRequest = async (rawRequest: string): Promise<ParsedLogoutRequest> => {
return new Promise((resolve, reject) => {
xml2js.parseString(
rawRequest,
{ tagNameProcessors: [xml2js.processors.stripPrefix] },
(err, parsedData) => {
if (err) {
reject(err);
return;
}

const { LogoutRequest } = parsedData;

if (!LogoutRequest) {
reject(new Error('Invalid SAML LogoutRequest: missing LogoutRequest element.'));
return;
}

const id = LogoutRequest.$.ID;
const destination = LogoutRequest.$.Destination;

const issuerElement = LogoutRequest.Issuer;
const issuer = issuerElement
? typeof issuerElement[0] === 'string'
? issuerElement[0]
: issuerElement[0]._
: '';

const nameIdElement = LogoutRequest.NameID;
const nameId = nameIdElement
? typeof nameIdElement[0] === 'string'
? nameIdElement[0]
: nameIdElement[0]._
: '';

const sessionIndexElement = LogoutRequest.SessionIndex;
const sessionIndex = sessionIndexElement
? typeof sessionIndexElement[0] === 'string'
? sessionIndexElement[0]
: sessionIndexElement[0]._
: undefined;

// Extract public key from Signature > KeyInfo > X509Data > X509Certificate if present
let publicKey: string | undefined;
const signature = LogoutRequest.Signature;
if (signature) {
try {
const keyInfo = signature[0]?.KeyInfo?.[0];
const x509Data = keyInfo?.X509Data?.[0];
const x509Cert = x509Data?.X509Certificate?.[0];
if (x509Cert) {
publicKey = typeof x509Cert === 'string' ? x509Cert : x509Cert._;
}
} catch {
// Signature parsing is best-effort
}
}

// Extract id_token from Extensions > IdToken if present
// The SP can embed the OIDC id_token inside the LogoutRequest so it is
// covered by the XML signature.
let idToken: string | undefined;
const extensions = LogoutRequest.Extensions;
if (extensions) {
try {
const idTokenElement = extensions[0]?.Attribute?.[0];
if (idTokenElement && idTokenElement.$ && idTokenElement.$.Name === 'id_token') {
idToken = idTokenElement.AttributeValue[0]._;
}
} catch {
// Extensions parsing is best-effort
}
}

resolve({
id,
issuer,
nameId,
sessionIndex,
destination,
publicKey,
idToken,
});
}
);
});
};

const createLogoutResponse = ({
requestId,
issuer,
destination,
status = 'urn:oasis:names:tc:SAML:2.0:status:Success',
}: LogoutResponseParams): { id: string; xml: string } => {
const id = '_' + crypto.randomBytes(10).toString('hex');

const xml = {
'samlp:LogoutResponse': {
'@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
'@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
'@ID': id,
'@Version': '2.0',
'@IssueInstant': new Date().toISOString(),
'@Destination': destination,
'@InResponseTo': requestId,
'saml:Issuer': {
'#text': issuer,
},
'samlp:Status': {
'samlp:StatusCode': {
'@Value': status,
},
},
},
};

return {
id,
xml: xmlbuilder.create(xml).end({}),
};
};

export { parseLogoutResponse, createLogoutRequest, parseLogoutRequest, createLogoutResponse };

export type { ParsedLogoutResponse, LogoutRequestParams, ParsedLogoutRequest, LogoutResponseParams };
16 changes: 16 additions & 0 deletions lib/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
const BEGIN = '-----BEGIN CERTIFICATE-----';
const END = '-----END CERTIFICATE-----';

const parseMetadata = async (idpMeta: string, validateOpts): Promise<Record<string, any>> => {

Check warning on line 12 in lib/metadata.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
return new Promise((resolve, reject) => {
// Some Providers do not escape the & character in the metadata, for now these have been encountered in errorURL
idpMeta = idpMeta.replace(/errorURL=".*?"/g, '');
Expand All @@ -34,7 +34,7 @@
let sloRedirectUrl: null | undefined = null;
let sloPostUrl: null | undefined = null;

let ssoDes: any = getAttribute(res, 'EntityDescriptor.IDPSSODescriptor', null);

Check warning on line 37 in lib/metadata.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
if (!ssoDes) {
ssoDes = getAttribute(res, 'EntityDescriptor.SPSSODescriptor', []);
if (ssoDes.length > 0) {
Expand Down Expand Up @@ -105,7 +105,7 @@
}
}

const ret: Record<string, any> = {

Check warning on line 108 in lib/metadata.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
sso: {},
slo: {},
};
Expand Down Expand Up @@ -185,11 +185,13 @@

const createIdPMetadataXML = ({
ssoUrl,
sloUrl,
entityId,
x509cert,
wantAuthnRequestsSigned,
}: {
ssoUrl: string;
sloUrl?: string;
entityId: string;
x509cert: string;
wantAuthnRequestsSigned: boolean;
Expand Down Expand Up @@ -229,6 +231,20 @@
'@Location': ssoUrl,
},
],
...(sloUrl
? {
'md:SingleLogoutService': [
{
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
'@Location': sloUrl,
},
{
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
'@Location': sloUrl,
},
],
}
: {}),
},
},
};
Expand All @@ -251,7 +267,7 @@
}): string => {
const today = new Date();

const keyDescriptor: any[] = [

Check warning on line 270 in lib/metadata.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
{
'@use': 'signing',
'ds:KeyInfo': {
Expand Down
1 change: 1 addition & 0 deletions test/assets/logout-request-with-idtoken.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="ONELOGIN_21df91a89767879fc0f7df6a1490c6000c81644d" Version="2.0" IssueInstant="2024-01-15T09:30:00Z" Destination="http://localhost:5225/api/identity-federation/slo"><saml:Issuer>https://twilio.com/saml2/entityId</saml:Issuer><samlp:Extensions><samlp:Attribute Name="id_token"><samlp:AttributeValue xsi:type="xs:string">eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.test</samlp:AttributeValue></samlp:Attribute></samlp:Extensions><saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">logout@boxyhq.com</saml:NameID></samlp:LogoutRequest>
1 change: 1 addition & 0 deletions test/assets/logout-request.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="ONELOGIN_21df91a89767879fc0f7df6a1490c6000c81644d" Version="2.0" IssueInstant="2024-01-15T09:30:00Z" Destination="http://localhost:5225/api/identity-federation/slo"><saml:Issuer>https://twilio.com/saml2/entityId</saml:Issuer><saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">logout@boxyhq.com</saml:NameID></samlp:LogoutRequest>
100 changes: 99 additions & 1 deletion test/lib/logout.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import assert from 'assert';
import fs from 'fs';
import { parseLogoutResponse, createLogoutRequest } from '../../lib/logout';
import {
parseLogoutResponse,
createLogoutRequest,
parseLogoutRequest,
createLogoutResponse,
} from '../../lib/logout';

const response = fs.readFileSync('./test/assets/logout-response.xml').toString();
const responseFailed = fs.readFileSync('./test/assets/logout-response-failed.xml').toString();
const responseInvalid = 'invalid_data';

const request = fs.readFileSync('./test/assets/logout-request.xml').toString();
const requestWithIdToken = fs.readFileSync('./test/assets/logout-request-with-idtoken.xml').toString();
const requestInvalid = 'invalid_data';

describe('logout.ts', function () {
it('response ok', async function () {
const res = await parseLogoutResponse(response);
Expand Down Expand Up @@ -47,4 +56,93 @@ describe('logout.ts', function () {
}
);
});

it('should parse a valid LogoutRequest', async function () {
const parsed = await parseLogoutRequest(request);

assert.strictEqual(
parsed.id,
'ONELOGIN_21df91a89767879fc0f7df6a1490c6000c81644d',
'Should extract the request ID'
);
assert.strictEqual(parsed.issuer, 'https://twilio.com/saml2/entityId', 'Should extract the issuer');
assert.strictEqual(parsed.nameId, 'logout@boxyhq.com', 'Should extract the NameID');
assert.strictEqual(
parsed.destination,
'http://localhost:5225/api/identity-federation/slo',
'Should extract the destination'
);
});

it('should parse a valid LogoutRequest with id_token', async function () {
const parsed = await parseLogoutRequest(requestWithIdToken);

assert.strictEqual(
parsed.idToken,
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.test',
'Should extract the id_token from Extensions'
);
assert.strictEqual(parsed.issuer, 'https://twilio.com/saml2/entityId', 'Should extract the issuer');
assert.strictEqual(parsed.nameId, 'logout@boxyhq.com', 'Should extract the NameID');
assert.strictEqual(
parsed.destination,
'http://localhost:5225/api/identity-federation/slo',
'Should extract the destination'
);
});

it('should throw an expected error for request containing invalid xml', async function () {
await assert.rejects(
async () => {
await parseLogoutRequest(requestInvalid);
},
(error: any) => {
assert.strictEqual(error.message.includes('Non-whitespace before first tag'), true);
return true;
}
);
});

it('should create a valid LogoutRequest', async function () {
const { id, xml } = createLogoutResponse({
requestId: 'original-request-id',
issuer: 'https://saml.boxyhq.com',
destination: 'https://twilio.com/saml2/slo',
});

assert.strictEqual(!!id, true, 'Should have an ID');
assert.strictEqual(id.startsWith('_'), true, 'ID should start with underscore');
assert.strictEqual(xml.includes('LogoutResponse'), true, 'Should contain LogoutResponse element');
assert.strictEqual(
xml.includes('InResponseTo="original-request-id"'),
true,
'Should contain InResponseTo attribute'
);
assert.strictEqual(
xml.includes('Destination="https://twilio.com/saml2/slo"'),
true,
'Should contain the destination'
);
assert.strictEqual(xml.includes('saml:Issuer'), true, 'Should contain the issuer element');
assert.strictEqual(
xml.includes('urn:oasis:names:tc:SAML:2.0:status:Success'),
true,
'Should have Success status'
);
});

it('should create a LogoutResponse with custom status', async function () {
const { xml } = createLogoutResponse({
requestId: 'test-id',
issuer: 'https://saml.boxyhq.com',
destination: 'https://twilio.com/saml2/slo',
status: 'urn:oasis:names:tc:SAML:2.0:status:Requester',
});

assert.strictEqual(
xml.includes('urn:oasis:names:tc:SAML:2.0:status:Requester'),
true,
'Should have custom status'
);
});
});
Loading
Loading