// Copyright (c) 2026 Probo Inc . // // 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 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 connect_v1 import ( "context" "errors" "net" "net/http" "github.com/elimity-com/scim" scimerrors "github.com/elimity-com/scim/errors" "github.com/elimity-com/scim/optional" "github.com/elimity-com/scim/schema" scimfilter "github.com/scim2/filter-parser/v2" "go.gearno.de/kit/httpserver" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/bearertoken" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" scimservice "go.probo.inc/probo/pkg/iam/scim" "go.probo.inc/probo/pkg/server/api/clientip" ) type ( ctxKey struct{ name string } SCIMHandler struct { iam *iam.Service logger *log.Logger } scimResourceHandler struct { handler *SCIMHandler } scimRequestContext struct { ctx context.Context config *coredata.SCIMConfiguration ipAddress net.IP method string path string userName string handler *scimResourceHandler } ) var ( scimConfigCtxKey = &ctxKey{name: "scim_config"} ) func NewSCIMHandler(iam *iam.Service, logger *log.Logger) *SCIMHandler { return &SCIMHandler{iam: iam, logger: logger} } func scimConfigFromContext(ctx context.Context) *coredata.SCIMConfiguration { config, _ := ctx.Value(scimConfigCtxKey).(*coredata.SCIMConfiguration) return config } // NewSCIMServer creates a new SCIM server using elimity-com/scim func NewSCIMServer(h *SCIMHandler) http.Handler { schema.SetAllowStringValues(true) resourceTypes := []scim.ResourceType{ { ID: optional.NewString("User"), Name: "User", Endpoint: "/Users", Description: optional.NewString("User Account"), Schema: scimservice.UserSchema(), SchemaExtensions: []scim.SchemaExtension{ {Schema: scimservice.EnterpriseUserSchema()}, }, Handler: &scimResourceHandler{handler: h}, }, } serverConfig := scim.ServiceProviderConfig{ SupportFiltering: true, SupportPatch: true, AuthenticationSchemes: []scim.AuthenticationScheme{ { Type: scim.AuthenticationTypeOauthBearerToken, Name: "OAuth Bearer Token", Description: "Authentication using OAuth Bearer Token", }, }, } server, err := scim.NewServer( &scim.ServerArgs{ ServiceProviderConfig: &serverConfig, ResourceTypes: resourceTypes, }, ) if err != nil { panic(err) } return server } // BearerTokenMiddleware validates the bearer token and sets the SCIM configuration in context func (h *SCIMHandler) BearerTokenMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" { httpserver.RenderError(w, http.StatusUnauthorized, errors.New("authorization header required")) return } token, err := bearertoken.Parse(authHeader) if err != nil { httpserver.RenderError(w, http.StatusUnauthorized, errors.New("invalid authorization header")) return } config, err := h.iam.SCIMService.ValidateToken(r.Context(), token) if err != nil { if _, ok := errors.AsType[*scimservice.ErrSCIMInvalidToken](err); ok { httpserver.RenderError(w, http.StatusUnauthorized, errors.New("invalid token")) return } h.logger.ErrorCtx(r.Context(), "SCIM token validation error", log.Error(err)) httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error")) return } ctx := context.WithValue(r.Context(), scimConfigCtxKey, config) next.ServeHTTP(w, r.WithContext(ctx)) }) } func (rc *scimRequestContext) logAndWrapError(err error, logMsg string) error { if scimErr, ok := errors.AsType[scimerrors.ScimError](err); ok { errMsg := scimErr.Detail // Don't reference profileID for 404 errors - the resource doesn't exist userName := rc.userName if scimErr.Status == http.StatusNotFound { userName = "" } rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, userName, rc.ipAddress, scimErr.Status, &errMsg) return err } rc.handler.handler.logger.ErrorCtx(rc.ctx, logMsg, log.Error(err)) errMsg := "internal server error" rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, rc.userName, rc.ipAddress, 500, &errMsg) return scimerrors.ScimErrorInternal } func (rc *scimRequestContext) logSuccess(statusCode int) { rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, rc.userName, rc.ipAddress, statusCode, nil) } func (h *scimResourceHandler) Create(r *http.Request, attributes scim.ResourceAttributes) (scim.Resource, error) { rc := &scimRequestContext{ ctx: r.Context(), config: scimConfigFromContext(r.Context()), ipAddress: getIPAddress(r), method: "POST", path: "/Users", handler: h, } resource, err := h.handler.iam.SCIMService.CreateUser(rc.ctx, rc.config, attributes) if err != nil { return scim.Resource{}, rc.logAndWrapError(err, "cannot create user") } rc.userName = resource.Attributes["userName"].(string) rc.logSuccess(201) return resource, nil } func (h *scimResourceHandler) Get(r *http.Request, id string) (scim.Resource, error) { rc := &scimRequestContext{ ctx: r.Context(), config: scimConfigFromContext(r.Context()), ipAddress: getIPAddress(r), method: "GET", path: "/Users/" + id, handler: h, } profileID, err := gid.ParseGID(id) if err != nil { return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid profile ID") } resource, err := h.handler.iam.SCIMService.GetUser(rc.ctx, rc.config, profileID) if err != nil { return scim.Resource{}, rc.logAndWrapError(err, "cannot get user") } rc.userName = resource.Attributes["userName"].(string) rc.logSuccess(200) return resource, nil } func (h *scimResourceHandler) GetAll(r *http.Request, params scim.ListRequestParams) (scim.Page, error) { path := "/Users" if r.URL.RawQuery != "" { path += "?" + r.URL.RawQuery } rc := &scimRequestContext{ ctx: r.Context(), config: scimConfigFromContext(r.Context()), ipAddress: getIPAddress(r), method: "GET", path: path, handler: h, } var filterExpr scimfilter.Expression if params.FilterValidator != nil { if err := params.FilterValidator.Validate(); err != nil { return scim.Page{}, rc.logAndWrapError(scimerrors.ScimErrorBadRequest(err.Error()), "invalid filter") } filterExpr = params.FilterValidator.GetFilter() } resources, totalCount, err := h.handler.iam.SCIMService.ListUsers(rc.ctx, rc.config, filterExpr, params.StartIndex, params.Count) if err != nil { return scim.Page{}, rc.logAndWrapError(err, "cannot list users") } rc.logSuccess(200) return scim.Page{ TotalResults: totalCount, Resources: resources, }, nil } func (h *scimResourceHandler) Replace(r *http.Request, id string, attributes scim.ResourceAttributes) (scim.Resource, error) { rc := &scimRequestContext{ ctx: r.Context(), config: scimConfigFromContext(r.Context()), ipAddress: getIPAddress(r), method: "PUT", path: "/Users/" + id, handler: h, } profileID, err := gid.ParseGID(id) if err != nil { return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid profile ID") } resource, err := h.handler.iam.SCIMService.ReplaceUser(rc.ctx, rc.config, profileID, attributes) if err != nil { return scim.Resource{}, rc.logAndWrapError(err, "cannot update user") } rc.userName = resource.Attributes["userName"].(string) rc.logSuccess(200) return resource, nil } func (h *scimResourceHandler) Patch(r *http.Request, id string, operations []scim.PatchOperation) (scim.Resource, error) { rc := &scimRequestContext{ ctx: r.Context(), config: scimConfigFromContext(r.Context()), ipAddress: getIPAddress(r), method: "PATCH", path: "/Users/" + id, handler: h, } profileID, err := gid.ParseGID(id) if err != nil { return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid profile ID") } resource, err := h.handler.iam.SCIMService.PatchUser(rc.ctx, rc.config, profileID, operations) if err != nil { return scim.Resource{}, rc.logAndWrapError(err, "cannot patch user") } rc.userName = resource.Attributes["userName"].(string) rc.logSuccess(200) return resource, nil } func (h *scimResourceHandler) Delete(r *http.Request, id string) error { rc := &scimRequestContext{ ctx: r.Context(), config: scimConfigFromContext(r.Context()), ipAddress: getIPAddress(r), method: "DELETE", path: "/Users/" + id, handler: h, } profileID, err := gid.ParseGID(id) if err != nil { return rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid profile ID") } err = h.handler.iam.SCIMService.DeleteUser(rc.ctx, rc.config, profileID) if err != nil { return rc.logAndWrapError(err, "cannot delete user") } rc.userName = "" rc.logSuccess(204) return nil } func getIPAddress(r *http.Request) net.IP { if ip := net.ParseIP(clientip.Extract(r)); ip != nil { return ip } return net.IPv4(127, 0, 0, 1) }