Skip to content

Commit

Permalink
Bed-5010 feat: Add ability to download SAML signing certificate (#981)
Browse files Browse the repository at this point in the history
  • Loading branch information
mistahj67 authored Nov 27, 2024
1 parent 929f251 commit e8ea43f
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 47 deletions.
4 changes: 3 additions & 1 deletion cmd/api/src/api/registration/v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
23 changes: 22 additions & 1 deletion cmd/api/src/api/v2/auth/saml.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]

Expand All @@ -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)
Expand All @@ -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 {
Expand Down
18 changes: 11 additions & 7 deletions packages/go/crypto/tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
62 changes: 62 additions & 0 deletions packages/go/openapi/doc/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
2 changes: 2 additions & 0 deletions packages/go/openapi/src/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -73,6 +77,7 @@ const SSOProviderInfoPanel: FC<{
const theme = useTheme();
const paneStyles = usePaneStyles();
const headerStyles = useHeaderStyles();
const { addNotification } = useNotifications();

if (!ssoProvider.type) {
return null;
Expand All @@ -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 (
<Box className={paneStyles.container} data-testid='sso_provider-info-panel'>
<Paper>
<Box className={headerStyles.header} sx={{ backgroundColor: theme.palette.neutral.quinary }}>
<Box
sx={{
backgroundColor: theme.palette.primary.main,
width: 10,
height: theme.spacing(7),
mr: theme.spacing(1),
}}
/>
<Typography
data-testid='sso_provider-info-panel_header-text'
variant={'h5'}
noWrap
<>
<Box className={paneStyles.container} data-testid='sso_provider-info-panel'>
<Paper>
<Box className={headerStyles.header} sx={{ backgroundColor: theme.palette.neutral.quinary }}>
<Box
sx={{
backgroundColor: theme.palette.primary.main,
width: 10,
height: theme.spacing(7),
mr: theme.spacing(1),
}}
/>
<Typography
data-testid='sso_provider-info-panel_header-text'
variant={'h5'}
noWrap
sx={{
color: theme.palette.text.primary,
flexGrow: 1,
}}>
{ssoProvider?.name}
</Typography>
</Box>
<Paper
elevation={0}
sx={{
color: theme.palette.text.primary,
flexGrow: 1,
backgroundColor: theme.palette.neutral.secondary,
overflowX: 'hidden',
overflowY: 'auto',
padding: theme.spacing(1, 2),
pointerEvents: 'auto',
'& > div.node:nth-of-type(odd)': {
background: theme.palette.neutral.tertiary,
},
}}>
{ssoProvider?.name}
</Typography>
</Box>
<Paper
elevation={0}
sx={{
backgroundColor: theme.palette.neutral.secondary,
overflowX: 'hidden',
overflowY: 'auto',
padding: theme.spacing(1, 2),
pointerEvents: 'auto',
'& > div.node:nth-of-type(odd)': {
background: theme.palette.neutral.tertiary,
},
}}>
<Box flexShrink={0} flexGrow={1} fontWeight='bold' ml={theme.spacing(1)} fontSize={'small'}>
Provider Information:
</Box>
{infoPanel}
<Box flexShrink={0} flexGrow={1} fontWeight='bold' ml={theme.spacing(1)} fontSize={'small'}>
Provider Information:
</Box>
{infoPanel}
</Paper>
</Paper>
</Paper>
</Box>
</Box>
{ssoProvider.type.toLowerCase() === 'saml' && (
<Box mt={theme.spacing(1)} justifyContent='center' display='flex'>
<Button
aria-label={`Download ${ssoProvider.name} SP Certificate`}
variant='secondary'
onClick={downloadSAMLSigningCertificate}>
Download SAML SP Certificate
</Button>
</Box>
)}
</>
);
};

Expand Down
3 changes: 3 additions & 0 deletions packages/javascript/js-client-library/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down

0 comments on commit e8ea43f

Please sign in to comment.