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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -45,7 +45,7 @@ type Query {
|
||||
|
||||
type OAuthClientBranding {
|
||||
name: String!
|
||||
logo: File
|
||||
logoUrl: String
|
||||
clientURL: String
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -47,9 +47,7 @@ func oauthClientBrandingFromIAM(
|
||||
}
|
||||
|
||||
if branding.LogoURL != nil {
|
||||
result.Logo = &types.File{
|
||||
DownloadURL: *branding.LogoURL,
|
||||
}
|
||||
result.LogoURL = branding.LogoURL
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user