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);