From e8ea43f9f42ff20d8fd56217eb2833282c65edf0 Mon Sep 17 00:00:00 2001
From: mistahj67 <26472282+mistahj67@users.noreply.github.com>
Date: Wed, 27 Nov 2024 15:51:21 -0700
Subject: [PATCH] Bed-5010 feat: Add ability to download SAML signing
certificate (#981)
---
cmd/api/src/api/registration/v2.go | 4 +-
cmd/api/src/api/v2/auth/saml.go | 23 +++-
packages/go/crypto/tls.go | 18 +--
packages/go/openapi/doc/openapi.json | 62 ++++++++++
packages/go/openapi/src/openapi.yaml | 2 +
....sso-providers.id.signing-certificate.yaml | 55 +++++++++
.../SSOProviderInfoPanel.tsx | 116 ++++++++++++------
.../js-client-library/src/client.ts | 3 +
8 files changed, 236 insertions(+), 47 deletions(-)
create mode 100644 packages/go/openapi/src/paths/sso.sso-providers.id.signing-certificate.yaml
diff --git a/cmd/api/src/api/registration/v2.go b/cmd/api/src/api/registration/v2.go
index 440745a73d..46a6181a74 100644
--- a/cmd/api/src/api/registration/v2.go
+++ b/cmd/api/src/api/registration/v2.go
@@ -60,9 +60,11 @@ func registerV2Auth(resources v2.Resources, routerInst *router.Router, permissio
routerInst.POST("/api/v2/sso-providers/oidc", managementResource.CreateOIDCProvider).CheckFeatureFlag(resources.DB, appcfg.FeatureOIDCSupport).RequirePermissions(permissions.AuthManageProviders),
routerInst.DELETE(fmt.Sprintf("/api/v2/sso-providers/{%s}", api.URIPathVariableSSOProviderID), managementResource.DeleteSSOProvider).RequirePermissions(permissions.AuthManageProviders),
routerInst.PATCH(fmt.Sprintf("/api/v2/sso-providers/{%s}", api.URIPathVariableSSOProviderID), managementResource.UpdateSSOProvider).RequirePermissions(permissions.AuthManageProviders),
+ routerInst.GET(fmt.Sprintf("/api/v2/sso-providers/{%s}/signing-certificate", api.URIPathVariableSSOProviderID), managementResource.ServeSigningCertificate).RequirePermissions(permissions.AuthManageProviders),
+
routerInst.GET(fmt.Sprintf("/api/v2/sso/{%s}/login", api.URIPathVariableSSOProviderSlug), managementResource.SSOLoginHandler),
- routerInst.GET(fmt.Sprintf("/api/v2/sso/{%s}/metadata", api.URIPathVariableSSOProviderSlug), managementResource.ServeMetadata),
routerInst.PathPrefix(fmt.Sprintf("/api/v2/sso/{%s}/callback", api.URIPathVariableSSOProviderSlug), http.HandlerFunc(managementResource.SSOCallbackHandler)),
+ routerInst.GET(fmt.Sprintf("/api/v2/sso/{%s}/metadata", api.URIPathVariableSSOProviderSlug), managementResource.ServeMetadata),
// Permissions
routerInst.GET("/api/v2/permissions", managementResource.ListPermissions).RequirePermissions(permissions.AuthManageSelf),
diff --git a/cmd/api/src/api/v2/auth/saml.go b/cmd/api/src/api/v2/auth/saml.go
index 89ac2c8800..41827e3f6c 100644
--- a/cmd/api/src/api/v2/auth/saml.go
+++ b/cmd/api/src/api/v2/auth/saml.go
@@ -28,6 +28,7 @@ import (
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlsp"
"github.com/gorilla/mux"
+ "github.com/specterops/bloodhound/crypto"
"github.com/specterops/bloodhound/headers"
"github.com/specterops/bloodhound/log"
"github.com/specterops/bloodhound/mediatypes"
@@ -255,7 +256,7 @@ func (s ManagementResource) UpdateSAMLProviderRequest(response http.ResponseWrit
}
}
-// Preserve old metadata endpoint
+// Preserve old metadata endpoint for saml providers
func (s ManagementResource) ServeMetadata(response http.ResponseWriter, request *http.Request) {
ssoProviderSlug := mux.Vars(request)[api.URIPathVariableSSOProviderSlug]
@@ -266,6 +267,7 @@ func (s ManagementResource) ServeMetadata(response http.ResponseWriter, request
} else if serviceProvider, err := auth.NewServiceProvider(*ctx.Get(request.Context()).Host, s.config, *ssoProvider.SAMLProvider); err != nil {
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, err.Error(), request), response)
} else {
+ // Note: This is the samlsp metadata tied to authenticate flow and will not be the same as the XML metadata used to import the SAML provider initially
if content, err := xml.MarshalIndent(serviceProvider.Metadata(), "", " "); err != nil {
log.Errorf("[SAML] XML marshalling failure during service provider encoding for %s: %v", ssoProvider.SAMLProvider.IssuerURI, err)
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response)
@@ -278,6 +280,25 @@ func (s ManagementResource) ServeMetadata(response http.ResponseWriter, request
}
}
+// Provide the saml provider certifcate
+func (s ManagementResource) ServeSigningCertificate(response http.ResponseWriter, request *http.Request) {
+ rawProviderID := mux.Vars(request)[api.URIPathVariableSSOProviderID]
+
+ if ssoProviderID, err := strconv.ParseInt(rawProviderID, 10, 32); err != nil {
+ api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusNotFound, api.ErrorResponseDetailsResourceNotFound, request), response)
+ } else if ssoProvider, err := s.db.GetSSOProviderById(request.Context(), int32(ssoProviderID)); err != nil {
+ api.HandleDatabaseError(request, response, err)
+ } else if ssoProvider.SAMLProvider == nil {
+ api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusNotFound, api.ErrorResponseDetailsResourceNotFound, request), response)
+ } else {
+ // Note this is the public cert not necessarily the IDP cert
+ response.Header().Set(headers.ContentDisposition.String(), fmt.Sprintf("attachment; filename=\"%s-signing-certificate.pem\"", ssoProvider.Slug))
+ if _, err := response.Write([]byte(crypto.FormatCert(s.config.SAML.ServiceProviderCertificate))); err != nil {
+ log.Errorf("[SAML] Failed to write response for serving signing certificate: %v", err)
+ }
+ }
+}
+
// HandleStartAuthFlow is called to start the SAML authentication process.
func (s ManagementResource) SAMLLoginHandler(response http.ResponseWriter, request *http.Request, ssoProvider model.SSOProvider) {
if ssoProvider.SAMLProvider == nil {
diff --git a/packages/go/crypto/tls.go b/packages/go/crypto/tls.go
index e0f8ab0ebc..9ee91b4c51 100644
--- a/packages/go/crypto/tls.go
+++ b/packages/go/crypto/tls.go
@@ -74,17 +74,21 @@ func X509ParseCert(cert string) (*x509.Certificate, error) {
}
}
-func X509ParsePair(cert, key string) (*x509.Certificate, *rsa.PrivateKey, error) {
- formattedCert := cert
-
- if !strings.HasPrefix("-----BEGIN CERTIFICATE-----", formattedCert) {
- formattedCert = "-----BEGIN CERTIFICATE-----\n" + formattedCert
+func FormatCert(cert string) string {
+ if !strings.HasPrefix(cert, "-----BEGIN CERTIFICATE-----") {
+ cert = "-----BEGIN CERTIFICATE-----\n" + cert
}
- if !strings.HasSuffix("-----END CERTIFICATE----- ", formattedCert) {
- formattedCert = formattedCert + "\n-----END CERTIFICATE----- "
+ if !strings.HasSuffix(cert, "-----END CERTIFICATE-----") {
+ cert = cert + "\n-----END CERTIFICATE-----"
}
+ return cert
+}
+
+func X509ParsePair(cert, key string) (*x509.Certificate, *rsa.PrivateKey, error) {
+ formattedCert := FormatCert(cert)
+
if certBlock, _ := pem.Decode([]byte(formattedCert)); certBlock == nil {
return nil, nil, fmt.Errorf("unable to decode cert")
} else if cert, err := x509.ParseCertificate(certBlock.Bytes); err != nil {
diff --git a/packages/go/openapi/doc/openapi.json b/packages/go/openapi/doc/openapi.json
index 31b8cb071f..2d18616602 100644
--- a/packages/go/openapi/doc/openapi.json
+++ b/packages/go/openapi/doc/openapi.json
@@ -801,6 +801,68 @@
}
}
},
+ "/api/v2/sso-providers/{sso_provider_id}/signing-certificate": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/header.prefer"
+ },
+ {
+ "description": "SSO Provider ID for a SAML provider",
+ "name": "sso_provider_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ }
+ }
+ ],
+ "get": {
+ "operationId": "GetSSOProviderSAMLSigningCertificate",
+ "summary": "Get SAML Provider Signing Certificate",
+ "description": "Download the SAML Provider Signing Certificate. Only applies to SAML providers.",
+ "tags": [
+ "Auth",
+ "Community",
+ "Enterprise"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "headers": {
+ "content-disposition": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "Suggested filename of structure \"{saml-slug}-signing-certificate.pem\""
+ }
+ },
+ "content": {
+ "text/plain": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/not-found"
+ },
+ "429": {
+ "$ref": "#/components/responses/too-many-requests"
+ },
+ "500": {
+ "$ref": "#/components/responses/internal-server-error"
+ }
+ }
+ }
+ },
"/api/v2/permissions": {
"parameters": [
{
diff --git a/packages/go/openapi/src/openapi.yaml b/packages/go/openapi/src/openapi.yaml
index e2072fe2e5..7a24725dc4 100644
--- a/packages/go/openapi/src/openapi.yaml
+++ b/packages/go/openapi/src/openapi.yaml
@@ -224,6 +224,8 @@ paths:
$ref: './paths/sso.sso-providers.oidc.yaml'
/api/v2/sso-providers/{sso_provider_id}:
$ref: './paths/sso.sso-providers.id.yaml'
+ /api/v2/sso-providers/{sso_provider_id}/signing-certificate:
+ $ref: './paths/sso.sso-providers.id.signing-certificate.yaml'
# permissions
/api/v2/permissions:
diff --git a/packages/go/openapi/src/paths/sso.sso-providers.id.signing-certificate.yaml b/packages/go/openapi/src/paths/sso.sso-providers.id.signing-certificate.yaml
new file mode 100644
index 0000000000..468f768d8a
--- /dev/null
+++ b/packages/go/openapi/src/paths/sso.sso-providers.id.signing-certificate.yaml
@@ -0,0 +1,55 @@
+# Copyright 2024 Specter Ops, Inc.
+#
+# Licensed under the Apache License, Version 2.0
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+parameters:
+ - $ref: './../parameters/header.prefer.yaml'
+ - description: SSO Provider ID for a SAML provider
+ name: sso_provider_id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int32
+get:
+ operationId: GetSSOProviderSAMLSigningCertificate
+ summary: Get SAML Provider Signing Certificate
+ description: Download the SAML Provider Signing Certificate. Only applies to SAML providers.
+ tags:
+ - Auth
+ - Community
+ - Enterprise
+ responses:
+ '200':
+ description: OK
+ headers:
+ content-disposition:
+ schema:
+ type: string
+ description: Suggested filename of structure "{saml-slug}-signing-certificate.pem"
+ content:
+ text/plain:
+ schema:
+ type: string
+ '401':
+ $ref: './../responses/unauthorized.yaml'
+ '403':
+ $ref: './../responses/forbidden.yaml'
+ '404':
+ $ref: './../responses/not-found.yaml'
+ '429':
+ $ref: './../responses/too-many-requests.yaml'
+ '500':
+ $ref: './../responses/internal-server-error.yaml'
diff --git a/packages/javascript/bh-shared-ui/src/components/SSOProviderInfoPanel/SSOProviderInfoPanel.tsx b/packages/javascript/bh-shared-ui/src/components/SSOProviderInfoPanel/SSOProviderInfoPanel.tsx
index 993609b5ad..081c72750d 100644
--- a/packages/javascript/bh-shared-ui/src/components/SSOProviderInfoPanel/SSOProviderInfoPanel.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/SSOProviderInfoPanel/SSOProviderInfoPanel.tsx
@@ -16,9 +16,13 @@
import { Paper, Box, Typography, useTheme } from '@mui/material';
import { FC } from 'react';
+import fileDownload from 'js-file-download';
import { OIDCProviderInfo, SAMLProviderInfo, SSOProvider } from 'js-client-library';
+import { Button } from '@bloodhoundenterprise/doodleui';
import { Field, FieldsContainer, usePaneStyles, useHeaderStyles } from '../../views/Explore';
import LabelWithCopy from '../LabelWithCopy';
+import { apiClient } from '../../utils';
+import { useNotifications } from '../../providers';
const SAMLProviderInfoPanel: FC<{
samlProviderDetails: SAMLProviderInfo;
@@ -73,6 +77,7 @@ const SSOProviderInfoPanel: FC<{
const theme = useTheme();
const paneStyles = usePaneStyles();
const headerStyles = useHeaderStyles();
+ const { addNotification } = useNotifications();
if (!ssoProvider.type) {
return null;
@@ -90,48 +95,83 @@ const SSOProviderInfoPanel: FC<{
infoPanel = null;
}
+ const downloadSAMLSigningCertificate = () => {
+ if (ssoProvider.type.toLowerCase() == 'oidc') {
+ addNotification('Only SAML providers support signing certificates.', 'errorDownloadSAMLSigningCertificate');
+ } else {
+ apiClient
+ .getSAMLProviderSigningCertificate(ssoProvider.id)
+ .then((res) => {
+ const filename =
+ res.headers['content-disposition']?.match(/^.*filename="(.*)"$/)?.[1] ||
+ `${ssoProvider.name}-signing-certificate`;
+
+ fileDownload(res.data, filename);
+ })
+ .catch((err) => {
+ console.error(err);
+ addNotification(
+ 'This file could not be downloaded. Please try again.',
+ 'downloadSAMLSigningCertificate'
+ );
+ });
+ }
+ };
+
return (
-
-
-
-
-
+
+
+
+
+
+ {ssoProvider?.name}
+
+
+ div.node:nth-of-type(odd)': {
+ background: theme.palette.neutral.tertiary,
+ },
}}>
- {ssoProvider?.name}
-
-
- div.node:nth-of-type(odd)': {
- background: theme.palette.neutral.tertiary,
- },
- }}>
-
- Provider Information:
-
- {infoPanel}
+
+ Provider Information:
+
+ {infoPanel}
+
-
-
+
+ {ssoProvider.type.toLowerCase() === 'saml' && (
+
+
+
+ )}
+ >
);
};
diff --git a/packages/javascript/js-client-library/src/client.ts b/packages/javascript/js-client-library/src/client.ts
index d03f854d1e..22a3f1908a 100644
--- a/packages/javascript/js-client-library/src/client.ts
+++ b/packages/javascript/js-client-library/src/client.ts
@@ -688,6 +688,9 @@ class BHEAPIClient {
options
);
+ getSAMLProviderSigningCertificate = (ssoProviderId: types.SSOProvider['id'], options?: types.RequestOptions) =>
+ this.baseClient.get(`/api/v2/sso-providers/${ssoProviderId}/signing-certificate`, options);
+
deleteSSOProvider = (ssoProviderId: types.SSOProvider['id'], options?: types.RequestOptions) =>
this.baseClient.delete(`/api/v2/sso-providers/${ssoProviderId}`, options);