From b836c6fde80a2b3e660d18d56ef8f3e1161a56cd Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Wed, 15 Jul 2026 10:58:58 +0200 Subject: [PATCH] Add compliance portal CIMD metadata helpers Build and validate client metadata documents for trust center hostnames so OAuth initiation can publish the correct redirect URIs. Signed-off-by: Bryan Frimin --- pkg/complianceportal/cimd.go | 114 ++++++++++++++++++++++++++++++ pkg/complianceportal/cimd_test.go | 67 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 pkg/complianceportal/cimd.go create mode 100644 pkg/complianceportal/cimd_test.go diff --git a/pkg/complianceportal/cimd.go b/pkg/complianceportal/cimd.go new file mode 100644 index 000000000..efafaec78 --- /dev/null +++ b/pkg/complianceportal/cimd.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package complianceportal + +import ( + "fmt" + "net/url" + + "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam/oauth2" +) + +const ( + VisitorOAuthScope = "openid profile email" + CIMDMetadataPath = "/.well-known/oauth-client-metadata" + OAuthCallbackPath = "/callback" +) + +func CIMDClientIDURL(portalBaseURL string) (string, error) { + parsed, err := url.Parse(portalBaseURL) + if err != nil { + return "", err + } + + parsed.Path = CIMDMetadataPath + parsed.RawQuery = "" + parsed.Fragment = "" + + return parsed.String(), nil +} + +func OAuthCallbackURL(portalBaseURL string) (string, error) { + parsed, err := url.Parse(portalBaseURL) + if err != nil { + return "", err + } + + parsed.Path = OAuthCallbackPath + parsed.RawQuery = "" + parsed.Fragment = "" + + return parsed.String(), nil +} + +func PortalBaseURLFromCIMDClientID(clientIDURL string) (string, error) { + parsed, err := url.Parse(clientIDURL) + if err != nil { + return "", fmt.Errorf("cannot parse cimd client_id URL: %w", err) + } + + parsed.Path = "" + parsed.RawQuery = "" + parsed.Fragment = "" + + return parsed.String(), nil +} + +func BuildClientMetadataDocument( + portal *coredata.TrustCenter, + portalBaseURL string, +) (oauth2.ClientMetadataDocument, error) { + clientID, err := CIMDClientIDURL(portalBaseURL) + if err != nil { + return oauth2.ClientMetadataDocument{}, fmt.Errorf("cannot build cimd client_id URL: %w", err) + } + + redirectURI, err := OAuthCallbackURL(portalBaseURL) + if err != nil { + return oauth2.ClientMetadataDocument{}, fmt.Errorf("cannot build oauth callback URL: %w", err) + } + + doc := oauth2.ClientMetadataDocument{ + ClientID: clientID, + ClientName: portal.Title, + RedirectURIs: []string{redirectURI}, + TokenEndpointAuthMethod: "none", + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + Scope: VisitorOAuthScope, + } + + if portal.WebsiteURL != nil && *portal.WebsiteURL != "" { + doc.ClientURI = *portal.WebsiteURL + } else { + doc.ClientURI = portalBaseURL + } + + if portal.LogoFileID != nil { + parsedBaseURL, err := baseurl.Parse(portalBaseURL) + if err == nil { + logoURI, err := parsedBaseURL. + WithPath("/api/files/v1/public/" + portal.LogoFileID.String()). + String() + if err == nil { + doc.LogoURI = logoURI + } + } + } + + return doc, nil +} diff --git a/pkg/complianceportal/cimd_test.go b/pkg/complianceportal/cimd_test.go new file mode 100644 index 000000000..772bc62f8 --- /dev/null +++ b/pkg/complianceportal/cimd_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package complianceportal + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" +) + +func TestCIMDClientIDURL(t *testing.T) { + t.Parallel() + + clientID, err := CIMDClientIDURL("https://acme.example.com/overview") + require.NoError(t, err) + assert.Equal(t, "https://acme.example.com/.well-known/oauth-client-metadata", clientID) +} + +func TestOAuthCallbackURL(t *testing.T) { + t.Parallel() + + callbackURL, err := OAuthCallbackURL("https://acme.example.com/") + require.NoError(t, err) + assert.Equal(t, "https://acme.example.com/callback", callbackURL) +} + +func TestPortalBaseURLFromCIMDClientID(t *testing.T) { + t.Parallel() + + baseURL, err := PortalBaseURLFromCIMDClientID( + "https://acme.example.com/.well-known/oauth-client-metadata", + ) + require.NoError(t, err) + assert.Equal(t, "https://acme.example.com", baseURL) +} + +func TestBuildClientMetadataDocument(t *testing.T) { + t.Parallel() + + websiteURL := "https://acme.example.com" + portal := &coredata.TrustCenter{ + Title: "Acme Trust Center", + WebsiteURL: &websiteURL, + } + + doc, err := BuildClientMetadataDocument(portal, "https://acme.example.com") + require.NoError(t, err) + assert.Equal(t, "https://acme.example.com/.well-known/oauth-client-metadata", doc.ClientID) + assert.Equal(t, "Acme Trust Center", doc.ClientName) + assert.Equal(t, []string{"https://acme.example.com/callback"}, doc.RedirectURIs) + assert.Equal(t, websiteURL, doc.ClientURI) + assert.Equal(t, VisitorOAuthScope, doc.Scope) +}