diff --git a/pkg/complianceportal/brand.go b/pkg/complianceportal/brand.go new file mode 100644 index 000000000..56973974f --- /dev/null +++ b/pkg/complianceportal/brand.go @@ -0,0 +1,46 @@ +// 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" +) + +const ( + BrandLogoPath = "/brand/logo" + BrandDarkLogoPath = "/brand/dark-logo" +) + +func BrandLogoURL(portalBaseURL string) (string, error) { + return brandAssetURL(portalBaseURL, BrandLogoPath) +} + +func BrandDarkLogoURL(portalBaseURL string) (string, error) { + return brandAssetURL(portalBaseURL, BrandDarkLogoPath) +} + +func brandAssetURL(portalBaseURL string, path string) (string, error) { + parsed, err := url.Parse(portalBaseURL) + if err != nil { + return "", fmt.Errorf("cannot parse portal base URL: %w", err) + } + + parsed.Path = path + parsed.RawQuery = "" + parsed.Fragment = "" + + return parsed.String(), nil +} diff --git a/pkg/complianceportal/cimd.go b/pkg/complianceportal/cimd.go index efafaec78..6b2bd671d 100644 --- a/pkg/complianceportal/cimd.go +++ b/pkg/complianceportal/cimd.go @@ -18,7 +18,6 @@ import ( "fmt" "net/url" - "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/iam/oauth2" ) @@ -55,10 +54,10 @@ func OAuthCallbackURL(portalBaseURL string) (string, error) { return parsed.String(), nil } -func PortalBaseURLFromCIMDClientID(clientIDURL string) (string, error) { - parsed, err := url.Parse(clientIDURL) +func PortalRootURL(rawURL string) (string, error) { + parsed, err := url.Parse(rawURL) if err != nil { - return "", fmt.Errorf("cannot parse cimd client_id URL: %w", err) + return "", fmt.Errorf("cannot parse portal URL: %w", err) } parsed.Path = "" @@ -68,6 +67,15 @@ func PortalBaseURLFromCIMDClientID(clientIDURL string) (string, error) { return parsed.String(), nil } +func PortalBaseURLFromCIMDClientID(clientIDURL string) (string, error) { + portalURL, err := PortalRootURL(clientIDURL) + if err != nil { + return "", fmt.Errorf("cannot parse cimd client_id URL: %w", err) + } + + return portalURL, nil +} + func BuildClientMetadataDocument( portal *coredata.TrustCenter, portalBaseURL string, @@ -82,9 +90,15 @@ func BuildClientMetadataDocument( return oauth2.ClientMetadataDocument{}, fmt.Errorf("cannot build oauth callback URL: %w", err) } + portalRootURL, err := PortalRootURL(portalBaseURL) + if err != nil { + return oauth2.ClientMetadataDocument{}, fmt.Errorf("cannot build cimd client_uri URL: %w", err) + } + doc := oauth2.ClientMetadataDocument{ ClientID: clientID, ClientName: portal.Title, + ClientURI: portalRootURL, RedirectURIs: []string{redirectURI}, TokenEndpointAuthMethod: "none", GrantTypes: []string{"authorization_code", "refresh_token"}, @@ -92,21 +106,10 @@ func BuildClientMetadataDocument( 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) + logoURI, err := BrandLogoURL(portalBaseURL) if err == nil { - logoURI, err := parsedBaseURL. - WithPath("/api/files/v1/public/" + portal.LogoFileID.String()). - String() - if err == nil { - doc.LogoURI = logoURI - } + doc.LogoURI = logoURI } } diff --git a/pkg/complianceportal/cimd_test.go b/pkg/complianceportal/cimd_test.go index 772bc62f8..38b4ec750 100644 --- a/pkg/complianceportal/cimd_test.go +++ b/pkg/complianceportal/cimd_test.go @@ -20,6 +20,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" ) func TestCIMDClientIDURL(t *testing.T) { @@ -48,20 +49,50 @@ func TestPortalBaseURLFromCIMDClientID(t *testing.T) { assert.Equal(t, "https://acme.example.com", baseURL) } +func TestBrandLogoURL(t *testing.T) { + t.Parallel() + + logoURL, err := BrandLogoURL("https://acme.example.com/page") + require.NoError(t, err) + assert.Equal(t, "https://acme.example.com/brand/logo", logoURL) + + darkLogoURL, err := BrandDarkLogoURL("https://acme.example.com/") + require.NoError(t, err) + assert.Equal(t, "https://acme.example.com/brand/dark-logo", darkLogoURL) +} + func TestBuildClientMetadataDocument(t *testing.T) { t.Parallel() - websiteURL := "https://acme.example.com" + websiteURL := "https://www.acme.com" portal := &coredata.TrustCenter{ Title: "Acme Trust Center", WebsiteURL: &websiteURL, } - doc, err := BuildClientMetadataDocument(portal, "https://acme.example.com") + doc, err := BuildClientMetadataDocument( + portal, + "https://acme.example.com/.well-known/oauth-client-metadata", + ) 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, "https://acme.example.com", doc.ClientURI) assert.Equal(t, VisitorOAuthScope, doc.Scope) + assert.Empty(t, doc.LogoURI) +} + +func TestBuildClientMetadataDocument_LogoURIUsesBrandLogoEndpoint(t *testing.T) { + t.Parallel() + + logoFileID := gid.MustParseGID("WR-qMrB5AAEAGQAAAZ9mIO8B8vDFQ-i3") + portal := &coredata.TrustCenter{ + Title: "Acme Trust Center", + LogoFileID: &logoFileID, + } + + doc, err := BuildClientMetadataDocument(portal, "https://acme.example.com") + require.NoError(t, err) + assert.Equal(t, "https://acme.example.com/brand/logo", doc.LogoURI) } diff --git a/pkg/iam/oauth2/cimd.go b/pkg/iam/oauth2/cimd.go index 9e8e34998..e1099b08a 100644 --- a/pkg/iam/oauth2/cimd.go +++ b/pkg/iam/oauth2/cimd.go @@ -526,3 +526,29 @@ func (s *Service) validateCIMDScopes(scopes coredata.OAuth2Scopes) error { return nil } + +func (s *Service) CIMDClientMetadata(ctx context.Context, clientIDRaw string) (*ClientMetadataDocument, error) { + if !IsCIMDClientID(clientIDRaw) { + return nil, nil + } + + doc, err := s.cimd.fetch(ctx, clientIDRaw) + if err != nil { + return nil, fmt.Errorf("cannot fetch cimd client metadata: %w", err) + } + + return doc, nil +} + +func (s *Service) CIMDClientDisplayName(ctx context.Context, clientIDRaw string) (*string, error) { + doc, err := s.CIMDClientMetadata(ctx, clientIDRaw) + if err != nil { + return nil, err + } + + if doc == nil || doc.ClientName == "" { + return nil, nil + } + + return &doc.ClientName, nil +} diff --git a/pkg/iam/oauth2/client_branding.go b/pkg/iam/oauth2/client_branding.go new file mode 100644 index 000000000..013d2df57 --- /dev/null +++ b/pkg/iam/oauth2/client_branding.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 oauth2 + +import ( + "context" + "errors" + + "go.probo.inc/probo/pkg/coredata" +) + +type ClientBranding struct { + Name string + ClientURL *string + LogoURL *string +} + +func (s *Service) ClientBranding(ctx context.Context, clientIDRaw string) (*ClientBranding, error) { + if clientIDRaw == "" { + return nil, nil + } + + client, err := s.resolveClient(ctx, nil, clientIDRaw) + if err != nil { + if _, ok := errors.AsType[*OAuth2Error](err); ok { + return nil, nil + } + + return nil, err + } + + return ClientBrandingFromClient(client), nil +} + +func ClientBrandingFromClient(client *coredata.OAuth2Client) *ClientBranding { + if client == nil || client.ClientName == "" { + return nil + } + + branding := &ClientBranding{ + Name: client.ClientName, + } + + if client.ClientURI != nil { + clientURL := client.ClientURI.String() + branding.ClientURL = &clientURL + } + + if client.LogoURI != nil { + logoURL := client.LogoURI.String() + branding.LogoURL = &logoURL + } + + return branding +} diff --git a/pkg/iam/oauth2/client_branding_test.go b/pkg/iam/oauth2/client_branding_test.go new file mode 100644 index 000000000..2c39f0403 --- /dev/null +++ b/pkg/iam/oauth2/client_branding_test.go @@ -0,0 +1,66 @@ +// 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 oauth2 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/uri" +) + +func TestClientBrandingFromClient(t *testing.T) { + t.Parallel() + + clientURL := uri.URI("https://example.com") + logoURL := uri.URI("https://example.com/logo.png") + + branding := ClientBrandingFromClient( + &coredata.OAuth2Client{ + ClientName: "Acme", + ClientURI: &clientURL, + LogoURI: &logoURL, + }, + ) + + require.NotNil(t, branding) + assert.Equal(t, "Acme", branding.Name) + assert.Equal(t, "https://example.com", *branding.ClientURL) + assert.Equal(t, "https://example.com/logo.png", *branding.LogoURL) +} + +func TestClientBranding_EmptyClientID(t *testing.T) { + t.Parallel() + + svc := NewService(nil, nil, "", log.NewLogger()) + + branding, err := svc.ClientBranding(context.Background(), "") + require.NoError(t, err) + assert.Nil(t, branding) +} + +func TestClientBranding_InvalidClientID(t *testing.T) { + t.Parallel() + + svc := NewService(nil, nil, "", log.NewLogger()) + + branding, err := svc.ClientBranding(context.Background(), "not-a-cimd-client") + require.NoError(t, err) + assert.Nil(t, branding) +} diff --git a/pkg/server/api/complianceportal/oauth.go b/pkg/server/api/complianceportal/oauth.go index 241060dfe..98ce4e141 100644 --- a/pkg/server/api/complianceportal/oauth.go +++ b/pkg/server/api/complianceportal/oauth.go @@ -22,6 +22,8 @@ const ( VisitorOAuthScope = portal.VisitorOAuthScope GraphQLPath = "/graphql" CIMDMetadataPath = portal.CIMDMetadataPath + BrandLogoPath = portal.BrandLogoPath + BrandDarkLogoPath = portal.BrandDarkLogoPath OAuthInitiatePath = "/initiate" OAuthCallbackPath = portal.OAuthCallbackPath ) diff --git a/pkg/server/api/complianceportal/v1/brand_logo_handler.go b/pkg/server/api/complianceportal/v1/brand_logo_handler.go new file mode 100644 index 000000000..a9b876249 --- /dev/null +++ b/pkg/server/api/complianceportal/v1/brand_logo_handler.go @@ -0,0 +1,93 @@ +// 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_v1 + +import ( + "errors" + "net/http" + + "go.gearno.de/kit/httpserver" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/filemanager" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/server/api/complianceportal" +) + +type brandLogoVariant int + +const ( + brandLogoVariantLogo brandLogoVariant = iota + brandLogoVariantDarkLogo +) + +type brandLogoHandler struct { + logger *log.Logger + fileManager *filemanager.Service + variant brandLogoVariant +} + +func NewBrandLogoHandler(logger *log.Logger, fileManager *filemanager.Service) http.Handler { + return &brandLogoHandler{ + logger: logger, + fileManager: fileManager, + variant: brandLogoVariantLogo, + } +} + +func NewBrandDarkLogoHandler(logger *log.Logger, fileManager *filemanager.Service) http.Handler { + return &brandLogoHandler{ + logger: logger, + fileManager: fileManager, + variant: brandLogoVariantDarkLogo, + } +} + +func (h *brandLogoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + compliancePage := complianceportal.CompliancePageFromContext(r.Context()) + if compliancePage == nil { + httpserver.RenderError(w, http.StatusNotFound, errNotFound) + return + } + + var fileID *gid.GID + switch h.variant { + case brandLogoVariantLogo: + fileID = compliancePage.LogoFileID + case brandLogoVariantDarkLogo: + fileID = compliancePage.DarkLogoFileID + } + + if fileID == nil { + httpserver.RenderError(w, http.StatusNotFound, errNotFound) + return + } + + file, err := h.fileManager.GetPublicFile(r.Context(), *fileID) + if errors.Is(err, coredata.ErrResourceNotFound) { + httpserver.RenderError(w, http.StatusNotFound, errNotFound) + return + } + + if err != nil { + h.logger.ErrorCtx(r.Context(), "cannot load compliance page brand logo", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, errInternal) + + return + } + + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, h.fileManager.GenerateFileURL(file), http.StatusTemporaryRedirect) +} diff --git a/pkg/server/api/complianceportal/v1/mux.go b/pkg/server/api/complianceportal/v1/mux.go index ff0afb21b..cd0c44ac7 100644 --- a/pkg/server/api/complianceportal/v1/mux.go +++ b/pkg/server/api/complianceportal/v1/mux.go @@ -24,6 +24,7 @@ import ( "go.gearno.de/kit/log" "go.gearno.de/x/ref" "go.probo.inc/probo/pkg/baseurl" + page "go.probo.inc/probo/pkg/complianceportal" visitor "go.probo.inc/probo/pkg/complianceportal/visitor" "go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/filemanager" @@ -53,7 +54,7 @@ type MuxConfig struct { } func NewMux(cfg MuxConfig) (http.Handler, error) { - webServer, err := NewServer(compliancePageHeadData(cfg.BaseURL)) + webServer, err := NewServer(compliancePageHeadData()) if err != nil { return nil, err } @@ -107,6 +108,8 @@ func NewMux(cfg MuxConfig) (http.Handler, error) { r.Use(complianceportal.NewCompliancePagePresenceMiddleware()) r.Method(http.MethodGet, complianceportal.CIMDMetadataPath, NewOAuthClientMetadataHandler()) + r.Method(http.MethodGet, complianceportal.BrandLogoPath, NewBrandLogoHandler(cfg.Logger, cfg.File)) + r.Method(http.MethodGet, complianceportal.BrandDarkLogoPath, NewBrandDarkLogoHandler(cfg.Logger, cfg.File)) r.Method(http.MethodGet, complianceportal.OAuthInitiatePath, oauthInitiateHandler) r.Method(http.MethodGet, complianceportal.OAuthCallbackPath, oauthCallbackHandler) @@ -131,7 +134,7 @@ func handleCustomDomain404(w http.ResponseWriter, r *http.Request) { httpserver.RenderError(w, http.StatusNotFound, errors.New("not found")) } -func compliancePageHeadData(baseURL *baseurl.BaseURL) HeadDataFunc { +func compliancePageHeadData() HeadDataFunc { return func(r *http.Request) HeadData { tc := complianceportal.CompliancePageFromContext(r.Context()) if tc == nil { @@ -151,8 +154,8 @@ func compliancePageHeadData(baseURL *baseurl.BaseURL) HeadDataFunc { OGURL: ref.UnrefOrZero(compliancePageBaseURL), } - if tc.LogoFileID != nil { - faviconURL, err := baseURL.WithPath("/api/files/v1/public/" + tc.LogoFileID.String()).String() + if tc.LogoFileID != nil && compliancePageBaseURL != nil { + faviconURL, err := page.BrandLogoURL(*compliancePageBaseURL) if err == nil { headData.FaviconURL = faviconURL } diff --git a/pkg/server/api/connect/v1/base_resolvers.go b/pkg/server/api/connect/v1/base_resolvers.go index 46c67045f..c27676cca 100644 --- a/pkg/server/api/connect/v1/base_resolvers.go +++ b/pkg/server/api/connect/v1/base_resolvers.go @@ -271,6 +271,21 @@ func (r *queryResolver) Oauth2ScopesSupported(ctx context.Context) ([]coredata.O return r.scopeRegistry.RegisteredScopes(), nil } +// OauthClientBranding is the resolver for the oauthClientBranding field. +func (r *queryResolver) OauthClientBranding(ctx context.Context, clientID *string) (*types.OAuthClientBranding, error) { + if clientID == nil || *clientID == "" { + return nil, nil + } + + branding, err := oauthClientBranding(ctx, r.Resolver, *clientID) + if err != nil { + r.logger.WarnCtx(ctx, "cannot resolve oauth client branding", log.Error(err)) + return nil, nil + } + + return branding, nil +} + // Mutation returns schema.MutationResolver implementation. func (r *Resolver) Mutation() schema.MutationResolver { return &mutationResolver{r} } diff --git a/pkg/server/api/connect/v1/graphql/base.graphql b/pkg/server/api/connect/v1/graphql/base.graphql index 89d693031..b808e69b4 100644 --- a/pkg/server/api/connect/v1/graphql/base.graphql +++ b/pkg/server/api/connect/v1/graphql/base.graphql @@ -38,6 +38,15 @@ type Query { oauth2ScopesSupported: [OAuth2Scope!]! @goField(forceResolver: true) @authentication(required: OPTIONAL) + oauthClientBranding(clientId: String): OAuthClientBranding + @goField(forceResolver: true) + @authentication(required: OPTIONAL) +} + +type OAuthClientBranding { + name: String! + logo: File + clientURL: String } type OIDCProviderInfo { diff --git a/pkg/server/api/connect/v1/oauth_client_branding.go b/pkg/server/api/connect/v1/oauth_client_branding.go new file mode 100644 index 000000000..213f534c5 --- /dev/null +++ b/pkg/server/api/connect/v1/oauth_client_branding.go @@ -0,0 +1,56 @@ +// 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 connect_v1 + +import ( + "context" + + "go.probo.inc/probo/pkg/iam/oauth2" + "go.probo.inc/probo/pkg/server/api/connect/v1/types" +) + +func oauthClientBranding( + ctx context.Context, + r *Resolver, + clientID string, +) (*types.OAuthClientBranding, error) { + branding, err := r.iam.OAuth2ServerService.ClientBranding(ctx, clientID) + if err != nil { + return nil, err + } + + if branding == nil { + return nil, nil + } + + return oauthClientBrandingFromIAM(branding) +} + +func oauthClientBrandingFromIAM( + branding *oauth2.ClientBranding, +) (*types.OAuthClientBranding, error) { + result := &types.OAuthClientBranding{ + Name: branding.Name, + ClientURL: branding.ClientURL, + } + + if branding.LogoURL != nil { + result.Logo = &types.File{ + DownloadURL: *branding.LogoURL, + } + } + + return result, nil +}