Skip to content
Open
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
13 changes: 7 additions & 6 deletions packages/kbn-mock-idp-plugin/public/role_switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,20 @@ export const useAuthenticator = (reloadPage = false) => {
const { services } = useKibana<CoreStart>();

return useAsyncFn(async (params: CreateSAMLResponseParams) => {
// Create SAML Response using Mock IDP
const response = await services.http.post<Record<string, string>>('/mock_idp/saml_response', {
body: JSON.stringify(params),
});

// Authenticate user with SAML response
if (reloadPage) {
const form = createForm('/api/security/saml/callback', response);
const { acsUrl, ...samlPayload } = response;
const callbackUrl = acsUrl ?? '/api/security/saml/callback';

if (reloadPage || acsUrl) {
const form = createForm(callbackUrl, samlPayload);
form.submit();
await new Promise(() => {}); // Never resolve
await new Promise(() => {});
} else {
await services.http.post('/api/security/saml/callback', {
body: JSON.stringify(response),
body: JSON.stringify(samlPayload),
asResponse: true,
rawResponse: true,
});
Expand Down
23 changes: 17 additions & 6 deletions packages/kbn-mock-idp-plugin/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
MOCK_IDP_LOGOUT_PATH,
projectTypeToAlias,
} from '@kbn/mock-idp-utils';
import { getSAMLRequestId } from '@kbn/mock-idp-utils/src/utils';
import { parseSAMLRequest } from '@kbn/mock-idp-utils/src/utils';

import type { ConfigType } from './config';

Expand Down Expand Up @@ -171,22 +171,33 @@ export const plugin: PluginInitializer<void, void, PluginSetupDependencies> = as
: {};

try {
const requestId = await getSAMLRequestId(request.body.url);
if (requestId) {
logger.info(`Sending SAML response for request ID: ${requestId}`);
const samlRequestInfo = await parseSAMLRequest(request.body.url);
if (samlRequestInfo?.requestId) {
logger.info(`Sending SAML response for request ID: ${samlRequestInfo.requestId}`);
}

const kibanaAcsUrl = `${protocol}://${hostname}:${port}${pathname}`;
const destinationUrl = samlRequestInfo?.acsUrl ?? kibanaAcsUrl;

const parsed = new URL(request.body.url, 'https://localhost');
const relayState = parsed.searchParams.get('RelayState') ?? undefined;

return response.ok({
body: {
SAMLResponse: await createSAMLResponse({
kibanaUrl: `${protocol}://${hostname}:${port}${pathname}`,
kibanaUrl: destinationUrl,
username: request.body.username,
full_name: request.body.full_name ?? undefined,
email: request.body.email ?? undefined,
roles: request.body.roles,
...(requestId ? { authnRequestId: requestId } : {}),
...(samlRequestInfo?.requestId
? { authnRequestId: samlRequestInfo.requestId }
: {}),
...(samlRequestInfo?.issuer ? { spEntityId: samlRequestInfo.issuer } : {}),
...serverlessOptions,
}),
...(samlRequestInfo?.acsUrl ? { acsUrl: samlRequestInfo.acsUrl } : {}),
...(relayState ? { RelayState: relayState } : {}),
},
});
} catch (err) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export const MOCK_IDP_UIAM_ORG_ADMIN_API_KEY =
export const MOCK_IDP_UIAM_COSMOS_DB_COLLECTION_API_KEYS = 'api-keys';
export const MOCK_IDP_UIAM_COSMOS_DB_COLLECTION_USERS = 'users';
export const MOCK_IDP_UIAM_COSMOS_DB_COLLECTION_TOKEN_INVALIDATION = 'token-invalidation';
export const MOCK_IDP_UIAM_COSMOS_DB_COLLECTION_OAUTH_CLIENTS = 'oauth-clients';
export const MOCK_IDP_UIAM_COSMOS_DB_COLLECTION_OAUTH_AUTHORIZATION_CODES =
'oauth-authorization-codes';
export const MOCK_IDP_UIAM_COSMOS_DB_COLLECTION_OAUTH_APP_CONNECTIONS = 'oauth-app-connections';
export const MOCK_IDP_UIAM_COSMOS_DB_NAME = 'uiam-db';
// Cosmos DB emulator uses a fixed key. For production, this should be retrieved from configuration.
export const MOCK_IDP_UIAM_COSMOS_DB_ACCESS_KEY =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export {
MOCK_IDP_UIAM_COSMOS_DB_COLLECTION_API_KEYS,
MOCK_IDP_UIAM_COSMOS_DB_COLLECTION_TOKEN_INVALIDATION,
MOCK_IDP_UIAM_COSMOS_DB_COLLECTION_USERS,
MOCK_IDP_UIAM_COSMOS_DB_COLLECTION_OAUTH_CLIENTS,
MOCK_IDP_UIAM_COSMOS_DB_COLLECTION_OAUTH_AUTHORIZATION_CODES,
MOCK_IDP_UIAM_COSMOS_DB_COLLECTION_OAUTH_APP_CONNECTIONS,
MOCK_IDP_UIAM_COSMOS_DB_INTERNAL_URL,
MOCK_IDP_UIAM_COSMOS_DB_NAME,
MOCK_IDP_UIAM_COSMOS_DB_URL,
Expand All @@ -42,6 +45,7 @@ export {

export {
createMockIdpMetadata,
createMockIdpMetadataForUiam,
createSAMLResponse,
ensureSAMLRoleMapping,
generateCosmosDBApiRequestHeaders,
Expand Down
98 changes: 83 additions & 15 deletions src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,39 @@ export async function createMockIdpMetadata(kibanaUrl: string) {
`;
}

/**
* SAML IdP metadata for UIAM (OAuth → SAML browser flow).
*
* UIAM only accepts a **single** `SingleSignOnService` with **HTTP-Redirect** binding
* (`SamlIdentityProviderMetadataParser`). The general {@link createMockIdpMetadata} includes
* both POST and Redirect SSO endpoints, which UIAM rejects.
*
* @param kibanaUrl Fully qualified URL where Kibana is hosted (including base path)
*/
export async function createMockIdpMetadataForUiam(kibanaUrl: string) {
const signingKey = await readFile(KBN_CERT_PATH);
const cert = new X509Certificate(signingKey);
const trimTrailingSlash = (url: string) => (url.endsWith('/') ? url.slice(0, -1) : url);

return `<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="${MOCK_IDP_ENTITY_ID}">
<md:IDPSSODescriptor WantAuthnRequestsSigned="false"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>${cert.raw.toString('base64')}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="${trimTrailingSlash(kibanaUrl)}${MOCK_IDP_LOGIN_PATH}" />
</md:IDPSSODescriptor>
</md:EntityDescriptor>
`;
}

/**
* Creates a SAML response that can be passed directly to the Kibana ACS endpoint to authenticate a user.
*
Expand Down Expand Up @@ -104,6 +137,8 @@ export async function createSAMLResponse(options: {
kibanaUrl: string;
/** ID from SAML authentication request */
authnRequestId?: string;
/** SP entity ID for AudienceRestriction (required by UIAM, optional for ES) */
spEntityId?: string;
username: string;
full_name?: string;
email?: string;
Expand Down Expand Up @@ -134,6 +169,15 @@ export async function createSAMLResponse(options: {
})
: undefined;

const conditionsXml = `
<saml:Conditions NotBefore="${issueInstant}" NotOnOrAfter="${notOnOrAfter}">
${
options.spEntityId
? `<saml:AudienceRestriction><saml:Audience>${options.spEntityId}</saml:Audience></saml:AudienceRestriction>`
: ''
}
</saml:Conditions>`;

const samlAssertionTemplateXML = `
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0" ID="_RPs1WfOkul8lZ72DtJtes0BKyPgaCamg" IssueInstant="${issueInstant}">
<saml:Issuer>${MOCK_IDP_ENTITY_ID}</saml:Issuer>
Expand All @@ -144,7 +188,7 @@ export async function createSAMLResponse(options: {
options.authnRequestId ? `InResponseTo="${options.authnRequestId}"` : ''
} Recipient="${options.kibanaUrl}" />
</saml:SubjectConfirmation>
</saml:Subject>
</saml:Subject>${conditionsXml}
<saml:AuthnStatement AuthnInstant="${issueInstant}" SessionIndex="4464894646681600">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef>
Expand Down Expand Up @@ -534,23 +578,47 @@ function wrapSignedJwt(signedJwt: string): string {
const inflateRawAsync = promisify(zlib.inflateRaw);
const parseStringAsync = promisify(parseString);

export async function getSAMLRequestId(requestUrl: string): Promise<string | undefined> {
const samlRequest = Url.parse(requestUrl, true /* parseQueryString */).query.SAMLRequest;
export interface SAMLRequestInfo {
requestId: string;
acsUrl?: string;
issuer?: string;
}

let requestId: string | undefined;
/**
* Parses a SAML AuthnRequest from the redirect URL and extracts the request ID
* and optional AssertionConsumerServiceURL. The ACS URL tells us where to POST
* the SAMLResponse (e.g. UIAM vs Kibana/ES).
*/
export async function parseSAMLRequest(requestUrl: string): Promise<SAMLRequestInfo | undefined> {
const parsed = Url.parse(requestUrl, true);
const samlRequest = parsed.query.SAMLRequest;

if (samlRequest) {
try {
const inflatedSAMLRequest = (await inflateRawAsync(
Buffer.from(samlRequest as string, 'base64')
)) as Buffer;
if (!samlRequest) {
return undefined;
}

const parsedSAMLRequest = (await parseStringAsync(inflatedSAMLRequest.toString())) as any;
requestId = parsedSAMLRequest['saml2p:AuthnRequest'].$.ID as string;
} catch (e) {
return undefined;
}
try {
const inflatedSAMLRequest = (await inflateRawAsync(
Buffer.from(samlRequest as string, 'base64')
)) as Buffer;

const parsedSAMLRequest = (await parseStringAsync(inflatedSAMLRequest.toString())) as any;
const authnRequest = parsedSAMLRequest['saml2p:AuthnRequest'];
const attrs = authnRequest.$;
const issuerElement = authnRequest['saml2:Issuer']?.[0];
const issuer =
typeof issuerElement === 'string' ? issuerElement : issuerElement?._ ?? undefined;
return {
requestId: attrs.ID as string,
acsUrl: attrs.AssertionConsumerServiceURL as string | undefined,
issuer,
};
} catch (e) {
return undefined;
}
}

return requestId;
export async function getSAMLRequestId(requestUrl: string): Promise<string | undefined> {
const info = await parseSAMLRequest(requestUrl);
return info?.requestId;
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ describe(`#runUiamContainer()`, () => {
"LOG_LEVEL=error",
"--health-cmd",
"curl -sk http://127.0.0.1:8080/ready | grep -q \\"\\\\\\"overall\\\\\\": true\\"",
"--env",
"UIAM_SERVICE_BOUNDARY=external",
"--name",
"uiam-cosmosdb",
"docker.elastic.co/kibana-ci/uiam-azure-cosmos-emulator:latest-verified",
Expand Down Expand Up @@ -394,7 +396,7 @@ describe('#initializeUiamContainers', () => {
expect(mockUndiciAgent).toHaveBeenCalledTimes(1);
expect(mockUndiciAgent).toHaveBeenCalledWith({ connect: { rejectUnauthorized: false } });

expect(mockUndiciFetch).toHaveBeenCalledTimes(4);
expect(mockUndiciFetch).toHaveBeenCalledTimes(7);
expect(mockUndiciFetch.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Expand Down Expand Up @@ -465,6 +467,57 @@ describe('#initializeUiamContainers', () => {
"method": "POST",
},
],
Array [
"https://localhost:8081/dbs/uiam-db/colls",
Object {
"body": "{\\"id\\":\\"oauth-clients\\",\\"partitionKey\\":{\\"paths\\":[\\"/creator_id\\"],\\"kind\\":\\"Hash\\"}}",
"dispatcher": Object {
"dispatch": [MockFunction],
"name": "I'm the danger. I'm the one who knocks.",
},
"headers": Object {
"Authorization": "type%3Dmaster%26ver%3D1.0%26sig%3Djxrkp7JRqa5BKBelNeJSwradPgHYz2aTrP8%2Bce0zMQY%3D",
"Content-Type": "application/json",
"x-ms-date": "Sat, 01 Jan 2000 00:00:00 GMT",
"x-ms-version": "2018-12-31",
},
"method": "POST",
},
],
Array [
"https://localhost:8081/dbs/uiam-db/colls",
Object {
"body": "{\\"id\\":\\"oauth-authorization-codes\\",\\"partitionKey\\":{\\"paths\\":[\\"/id\\"],\\"kind\\":\\"Hash\\"}}",
"dispatcher": Object {
"dispatch": [MockFunction],
"name": "I'm the danger. I'm the one who knocks.",
},
"headers": Object {
"Authorization": "type%3Dmaster%26ver%3D1.0%26sig%3Djxrkp7JRqa5BKBelNeJSwradPgHYz2aTrP8%2Bce0zMQY%3D",
"Content-Type": "application/json",
"x-ms-date": "Sat, 01 Jan 2000 00:00:00 GMT",
"x-ms-version": "2018-12-31",
},
"method": "POST",
},
],
Array [
"https://localhost:8081/dbs/uiam-db/colls",
Object {
"body": "{\\"id\\":\\"oauth-app-connections\\",\\"partitionKey\\":{\\"paths\\":[\\"/client_id\\"],\\"kind\\":\\"Hash\\"}}",
"dispatcher": Object {
"dispatch": [MockFunction],
"name": "I'm the danger. I'm the one who knocks.",
},
"headers": Object {
"Authorization": "type%3Dmaster%26ver%3D1.0%26sig%3Djxrkp7JRqa5BKBelNeJSwradPgHYz2aTrP8%2Bce0zMQY%3D",
"Content-Type": "application/json",
"x-ms-date": "Sat, 01 Jan 2000 00:00:00 GMT",
"x-ms-version": "2018-12-31",
},
"method": "POST",
},
],
]
`);
});
Expand All @@ -477,7 +530,7 @@ describe('#initializeUiamContainers', () => {
expect(mockUndiciAgent).toHaveBeenCalledTimes(1);
expect(mockUndiciAgent).toHaveBeenCalledWith({ connect: { rejectUnauthorized: false } });

expect(mockUndiciFetch).toHaveBeenCalledTimes(4);
expect(mockUndiciFetch).toHaveBeenCalledTimes(7);
});

test('fails if cannot create database', async () => {
Expand Down
Loading
Loading