Harden compliance portal auth and TLS

Align console references and OAuth branding with the
compliance-page model, and fix certificate cache eviction,
portal OAuth handlers, and magic-link edge cases left after
the trust-center rename.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-20 09:59:25 +02:00
parent b03acbd029
commit 43ce3a7c53
51 changed files with 626 additions and 458 deletions

View File

@@ -16,6 +16,7 @@ import (
"go.probo.inc/probo/pkg/server/api/complianceportal"
"go.probo.inc/probo/pkg/server/api/complianceportal/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/validator"
)
// UpdateFullName is the resolver for the updateFullName field.
@@ -25,43 +26,66 @@ func (r *mutationResolver) UpdateFullName(ctx context.Context, input types.Updat
return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to request access")
}
identity, err := r.iam.AccountService.UpdateIdentity(
ctx,
identity.ID,
&iam.UpdateIdentityRequest{
FullName: input.FullName,
},
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot update identity", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
compliancePage := complianceportal.CompliancePageFromContext(ctx)
profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, compliancePage.OrganizationID)
if err != nil {
// External trust-center visitors have no organization profile; updating
// the identity's full name above is all that is needed for them.
if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); ok {
return &types.UpdateFullNamePayload{Success: true}, nil
// External trust-center visitors have no organization profile; only
// their identity needs updating.
if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); !ok {
r.logger.ErrorCtx(ctx, "cannot get profile", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
r.logger.ErrorCtx(ctx, "cannot get profile", log.Error(err))
return nil, gqlutils.Internal(ctx)
profile = nil
}
if profile.Source == coredata.ProfileSourceManual {
if _, err := r.iam.OrganizationService.UpdateUser(ctx, &iam.UpdateUserRequest{
// The identity and profile full names are validated by different rules.
// Validate the profile update up front so it cannot fail after the
// identity has already been mutated, keeping the two in sync.
var updateUserRequest *iam.UpdateUserRequest
if profile != nil && profile.Source == coredata.ProfileSourceManual {
updateUserRequest = &iam.UpdateUserRequest{
ID: profile.ID,
FullName: identity.FullName,
FullName: input.FullName,
AdditionalEmailAddresses: profile.AdditionalEmailAddresses,
Kind: profile.Kind,
Position: profile.Position,
ContractStartDate: &profile.ContractStartDate,
ContractEndDate: &profile.ContractEndDate,
}); err != nil {
}
if err := updateUserRequest.Validate(); err != nil {
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
r.logger.ErrorCtx(ctx, "cannot validate profile update", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
}
if _, err := r.iam.AccountService.UpdateIdentity(
ctx,
identity.ID,
&iam.UpdateIdentityRequest{
FullName: input.FullName,
},
); err != nil {
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
r.logger.ErrorCtx(ctx, "cannot update identity", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if updateUserRequest != nil {
if _, err := r.iam.OrganizationService.UpdateUser(ctx, updateUserRequest); err != nil {
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
r.logger.ErrorCtx(ctx, "cannot update profile", log.Error(err))
return nil, gqlutils.Internal(ctx)
}

View File

@@ -104,7 +104,7 @@ func NewMux(cfg MuxConfig) (http.Handler, error) {
func(r chi.Router) {
r.Use(complianceportal.NewCompliancePagePresenceMiddleware())
r.Method(http.MethodGet, complianceportal.CIMDMetadataPath, NewOAuthClientMetadataHandler())
r.Method(http.MethodGet, complianceportal.CIMDMetadataPath, NewOAuthClientMetadataHandler(cfg.Visitor))
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)

View File

@@ -100,7 +100,15 @@ func (h *OAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
clientID, err := complianceportal.CIMDClientIDURL(*portalBaseURL)
canonicalBaseURL, err := h.visitor.GetPortalCanonicalBaseURL(ctx, portal.ID, *portalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot resolve canonical portal base URL", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
return
}
clientID, err := complianceportal.CIMDClientIDURL(canonicalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
@@ -108,7 +116,7 @@ func (h *OAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
redirectURI, err := complianceportal.OAuthCallbackURL(*portalBaseURL)
redirectURI, err := complianceportal.OAuthCallbackURL(canonicalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)

View File

@@ -23,22 +23,32 @@ import (
"go.probo.inc/probo/pkg/server/api/complianceportal"
)
type oauthClientMetadataHandler struct{}
type oauthClientMetadataHandler struct {
visitor *visitor.Service
}
func NewOAuthClientMetadataHandler() http.Handler {
return &oauthClientMetadataHandler{}
func NewOAuthClientMetadataHandler(visitorSvc *visitor.Service) http.Handler {
return &oauthClientMetadataHandler{visitor: visitorSvc}
}
func (h *oauthClientMetadataHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
compliancePage := complianceportal.CompliancePageFromContext(r.Context())
baseURL := complianceportal.CompliancePageBaseURLFromContext(r.Context())
ctx := r.Context()
compliancePage := complianceportal.CompliancePageFromContext(ctx)
baseURL := complianceportal.CompliancePageBaseURLFromContext(ctx)
if compliancePage == nil || baseURL == nil {
httpserver.RenderError(w, http.StatusNotFound, errNotFound)
return
}
doc, err := visitor.BuildClientMetadataDocument(compliancePage, *baseURL)
canonicalBaseURL, err := h.visitor.GetPortalCanonicalBaseURL(ctx, compliancePage.ID, *baseURL)
if err != nil {
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
return
}
doc, err := visitor.BuildClientMetadataDocument(compliancePage, canonicalBaseURL)
if err != nil {
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
return

View File

@@ -58,8 +58,9 @@ func NewOAuthInitiateHandler(
func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
compliancePage := complianceportal.CompliancePageFromContext(ctx)
portalBaseURL := complianceportal.CompliancePageBaseURLFromContext(ctx)
if portalBaseURL == nil {
if compliancePage == nil || portalBaseURL == nil {
httpserver.RenderError(w, http.StatusNotFound, errNotFound)
return
}
@@ -75,7 +76,15 @@ func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
clientID, err := complianceportal.CIMDClientIDURL(*portalBaseURL)
canonicalBaseURL, err := h.visitor.GetPortalCanonicalBaseURL(ctx, compliancePage.ID, *portalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot resolve canonical portal base URL", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
return
}
clientID, err := complianceportal.CIMDClientIDURL(canonicalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
@@ -83,7 +92,7 @@ func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
redirectURI, err := complianceportal.OAuthCallbackURL(*portalBaseURL)
redirectURI, err := complianceportal.OAuthCallbackURL(canonicalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)

View File

@@ -45,7 +45,7 @@ type Query {
type OAuthClientBranding {
name: String!
logo: File
logoUrl: String
clientURL: String
}

View File

@@ -25,7 +25,6 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/complianceportal/visitor"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
@@ -39,7 +38,6 @@ import (
func NewGraphQLHandler(
svc *iam.Service,
trustSvc *visitor.Service,
logger *log.Logger,
fileManagerSvc *filemanager.Service,
baseURL *baseurl.BaseURL,
@@ -52,7 +50,6 @@ func NewGraphQLHandler(
batchAuthorize: authz.NewBatchAuthorizeFunc(svc, logger),
logger: logger,
iam: svc,
trust: trustSvc,
scopeRegistry: svc.OAuth2ScopeRegistry,
fileManager: fileManagerSvc,
baseURL: baseURL,

View File

@@ -47,9 +47,7 @@ func oauthClientBrandingFromIAM(
}
if branding.LogoURL != nil {
result.Logo = &types.File{
DownloadURL: *branding.LogoURL,
}
result.LogoURL = branding.LogoURL
}
return result, nil

View File

@@ -68,7 +68,6 @@ type (
batchAuthorize authz.BatchAuthorizeFunc
logger *log.Logger
iam *iam.Service
trust *visitor.Service
scopeRegistry *oauth2scope.Registry
fileManager *filemanager.Service
baseURL *baseurl.BaseURL
@@ -93,7 +92,7 @@ func NewMux(
apiKeyMiddleware := authn.NewAPIKeyMiddleware(svc, tokenSecret)
oauth2Middleware := authn.NewOAuth2AccessTokenMiddleware(svc)
identityPresenceMiddleware := authn.NewIdentityPresenceMiddleware(baseURL)
graphqlHandler := NewGraphQLHandler(svc, trustSvc, logger, fileManagerSvc, baseURL, cookieConfig, graphqlLimits)
graphqlHandler := NewGraphQLHandler(svc, logger, fileManagerSvc, baseURL, cookieConfig, graphqlLimits)
samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger)
scimHandler := NewSCIMHandler(svc, logger.Named("scim"))

View File

@@ -1,16 +1,22 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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.
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// 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.
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package server