From 2219c24dabb8d10872f8b7c57cca859e48111ae4 Mon Sep 17 00:00:00 2001 From: Roman Perekhod <2403905@gmail.com> Date: Wed, 15 Oct 2025 09:35:06 +0200 Subject: [PATCH 01/15] fix: fixed the OCM WebDAV protocol entity mismatch --- changelog/unreleased/fix-ocm-weddav.md | 5 +++ .../ocmshareprovider/ocmshareprovider.go | 2 +- internal/http/services/ocmd/protocols.go | 34 +++++++++++++++- internal/http/services/ocmd/protocols_test.go | 40 ++++++++++++++++--- 4 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 changelog/unreleased/fix-ocm-weddav.md diff --git a/changelog/unreleased/fix-ocm-weddav.md b/changelog/unreleased/fix-ocm-weddav.md new file mode 100644 index 0000000000..b4c311d92b --- /dev/null +++ b/changelog/unreleased/fix-ocm-weddav.md @@ -0,0 +1,5 @@ +Bugfix: Fix the OCM WebDAV protocol entity mismatch + +Fixed the OCM WebDAV protocol entity mismatch + +https://github.com/owncloud/reva/pull/425 diff --git a/internal/grpc/services/ocmshareprovider/ocmshareprovider.go b/internal/grpc/services/ocmshareprovider/ocmshareprovider.go index ba1fdaaca3..3b2b89fa62 100644 --- a/internal/grpc/services/ocmshareprovider/ocmshareprovider.go +++ b/internal/grpc/services/ocmshareprovider/ocmshareprovider.go @@ -187,7 +187,7 @@ func (s *service) getWebdavProtocol(ctx context.Context, share *ocm.Share, m *oc return &ocmd.WebDAV{ Permissions: perms, - URL: s.webdavURL(ctx, share), + URI: s.webdavURL(ctx, share), SharedSecret: share.Token, } } diff --git a/internal/http/services/ocmd/protocols.go b/internal/http/services/ocmd/protocols.go index 00e2d51f11..c8e10834be 100644 --- a/internal/http/services/ocmd/protocols.go +++ b/internal/http/services/ocmd/protocols.go @@ -47,7 +47,37 @@ type Protocol interface { type WebDAV struct { SharedSecret string `json:"sharedSecret" validate:"required"` Permissions []string `json:"permissions" validate:"required,dive,required,oneof=read write share"` - URL string `json:"url" validate:"required"` + URI string `json:"uri" validate:"required"` +} + +// UnmarshalJSON implements custom JSON unmarshaling for backward compatibility. +// It supports both "url" (legacy) and "uri" (new) field names. +func (w *WebDAV) UnmarshalJSON(data []byte) error { + // Define a temporary struct with both url and uri fields + type WebDAVAlias struct { + SharedSecret string `json:"sharedSecret"` + Permissions []string `json:"permissions"` + URL string `json:"url"` + URI string `json:"uri"` + } + + var alias WebDAVAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + + // Copy common fields + w.SharedSecret = alias.SharedSecret + w.Permissions = alias.Permissions + + // Use URI if present, otherwise fall back to URL for backward compatibility + if alias.URI != "" { + w.URI = alias.URI + } else { + w.URI = alias.URL + } + + return nil } // ToOCMProtocol convert the protocol to a ocm Protocol struct. @@ -73,7 +103,7 @@ func (w *WebDAV) ToOCMProtocol() *ocm.Protocol { } } - return ocmshare.NewWebDAVProtocol(w.URL, w.SharedSecret, perms) + return ocmshare.NewWebDAVProtocol(w.URI, w.SharedSecret, perms) } // Webapp contains the parameters for the Webapp protocol. diff --git a/internal/http/services/ocmd/protocols_test.go b/internal/http/services/ocmd/protocols_test.go index 35d857f062..b844b94651 100644 --- a/internal/http/services/ocmd/protocols_test.go +++ b/internal/http/services/ocmd/protocols_test.go @@ -48,13 +48,23 @@ func TestUnmarshalProtocol(t *testing.T) { raw: `{"unsupported":{}}`, err: "protocol unsupported not recognised", }, + { + raw: `{"name":"multi","options":{},"webdav":{"sharedSecret":"secret","permissions":["read","write"],"uri":"http://example.org"}}`, + expected: []Protocol{ + &WebDAV{ + SharedSecret: "secret", + Permissions: []string{"read", "write"}, + URI: "http://example.org", + }, + }, + }, { raw: `{"name":"multi","options":{},"webdav":{"sharedSecret":"secret","permissions":["read","write"],"url":"http://example.org"}}`, expected: []Protocol{ &WebDAV{ SharedSecret: "secret", Permissions: []string{"read", "write"}, - URL: "http://example.org", + URI: "http://example.org", }, }, }, @@ -82,7 +92,25 @@ func TestUnmarshalProtocol(t *testing.T) { &WebDAV{ SharedSecret: "secret", Permissions: []string{"read", "write"}, - URL: "http://example.org", + URI: "http://example.org", + }, + &Webapp{ + URITemplate: "http://example.org/{test}", + }, + &Datatx{ + SharedSecret: "secret", + SourceURI: "http://example.org", + Size: 10, + }, + }, + }, + { + raw: `{"name":"multi","options":{},"webdav":{"sharedSecret":"secret","permissions":["read","write"],"uri":"http://example.org"},"webapp":{"uriTemplate":"http://example.org/{test}"},"datatx":{"sharedSecret":"secret","srcUri":"http://example.org","size":10}}`, + expected: []Protocol{ + &WebDAV{ + SharedSecret: "secret", + Permissions: []string{"read", "write"}, + URI: "http://example.org", }, &Webapp{ URITemplate: "http://example.org/{test}", @@ -145,7 +173,7 @@ func TestMarshalProtocol(t *testing.T) { &WebDAV{ SharedSecret: "secret", Permissions: []string{"read"}, - URL: "http://example.org", + URI: "http://example.org", }, }, expected: map[string]any{ @@ -154,7 +182,7 @@ func TestMarshalProtocol(t *testing.T) { "webdav": map[string]any{ "sharedSecret": "secret", "permissions": []any{"read"}, - "url": "http://example.org", + "uri": "http://example.org", }, }, }, @@ -197,7 +225,7 @@ func TestMarshalProtocol(t *testing.T) { &WebDAV{ SharedSecret: "secret", Permissions: []string{"read"}, - URL: "http://example.org", + URI: "http://example.org", }, &Webapp{ URITemplate: "http://example.org", @@ -215,7 +243,7 @@ func TestMarshalProtocol(t *testing.T) { "webdav": map[string]any{ "sharedSecret": "secret", "permissions": []any{"read"}, - "url": "http://example.org", + "uri": "http://example.org", }, "webapp": map[string]any{ "uriTemplate": "http://example.org", From 080ddafbdad3a7c6505e2708d594f4f52722ab2b Mon Sep 17 00:00:00 2001 From: Mahdi Baghbani Date: Fri, 17 Oct 2025 07:14:04 +0000 Subject: [PATCH 02/15] fix(ocm): OCM Specification Compliance Signed-off-by: Mahdi Baghbani --- .../ocminvitemanager/ocminvitemanager.go | 11 ++++++-- .../ocmshareprovider/ocmshareprovider.go | 8 +++--- internal/http/services/ocmd/invites.go | 6 ++-- internal/http/services/ocmd/shares.go | 27 ++++++++++++++---- pkg/ocm/user/user.go | 28 +++++++++++++++---- tests/integration/grpc/ocm_invitation_test.go | 5 ++-- tests/integration/grpc/ocm_share_test.go | 9 +++--- 7 files changed, 64 insertions(+), 30 deletions(-) diff --git a/internal/grpc/services/ocminvitemanager/ocminvitemanager.go b/internal/grpc/services/ocminvitemanager/ocminvitemanager.go index 05f92c91ae..cb44d1a9c6 100644 --- a/internal/grpc/services/ocminvitemanager/ocminvitemanager.go +++ b/internal/grpc/services/ocminvitemanager/ocminvitemanager.go @@ -180,9 +180,9 @@ func (s *service) ForwardInvite(ctx context.Context, req *invitepb.ForwardInvite remoteUser, err := s.ocmClient.InviteAccepted(ctx, ocmEndpoint, &client.InviteAcceptedRequest{ Token: req.InviteToken.GetToken(), RecipientProvider: s.conf.ProviderDomain, - // The UserID is only a string here. To not loose the IDP information we use the FederatedID encoding - // i.e. base64(UserID@IDP) - UserID: ocmuser.FederatedID(user.GetId(), "").GetOpaqueId(), + // The UserID is only a string here. To not lose the IDP information we use the LocalUserFederatedID encoding + // i.e. UserID@IDP + UserID: ocmuser.LocalUserFederatedID(user.GetId(), "").GetOpaqueId(), Email: user.GetMail(), Name: user.GetDisplayName(), }) @@ -223,6 +223,9 @@ func (s *service) ForwardInvite(ctx context.Context, req *invitepb.ForwardInvite OpaqueId: remoteUser.UserID, } + // we need to use a unique identifier for federated users + remoteUserID = ocmuser.EncodeRemoteUserFederatedID(remoteUserID) + if err := s.repo.AddRemoteUser(ctx, user.Id, &userpb.User{ Id: remoteUserID, Mail: remoteUser.Email, @@ -282,6 +285,8 @@ func (s *service) AcceptInvite(ctx context.Context, req *invitepb.AcceptInviteRe } remoteUser := req.GetRemoteUser() + // we need to use a unique identifier for federated users + remoteUser.Id = ocmuser.EncodeRemoteUserFederatedID(remoteUser.Id) if err := s.repo.AddRemoteUser(ctx, token.GetUserId(), remoteUser); err != nil { if !errors.Is(err, invite.ErrUserAlreadyAccepted) { diff --git a/internal/grpc/services/ocmshareprovider/ocmshareprovider.go b/internal/grpc/services/ocmshareprovider/ocmshareprovider.go index 3b2b89fa62..26ee674aa7 100644 --- a/internal/grpc/services/ocmshareprovider/ocmshareprovider.go +++ b/internal/grpc/services/ocmshareprovider/ocmshareprovider.go @@ -325,11 +325,11 @@ func (s *service) CreateOCMShare(ctx context.Context, req *ocm.CreateOCMShareReq // 2.b replace outgoing user ids with ocm user ids // unpack the federated user id - shareWith := ocmuser.FormatOCMUser(ocmuser.RemoteID(req.GetGrantee().GetUserId())) + shareWith := ocmuser.FormatOCMUser(ocmuser.DecodeRemoteUserFederatedID(req.GetGrantee().GetUserId())) - // wrap the local user id in a federated user id - owner := ocmuser.FormatOCMUser(ocmuser.FederatedID(info.Owner, s.conf.ProviderDomain)) - sender := ocmuser.FormatOCMUser(ocmuser.FederatedID(user.Id, s.conf.ProviderDomain)) + // wrap the local user id in a local federated user id + owner := ocmuser.FormatOCMUser(ocmuser.LocalUserFederatedID(info.Owner, s.conf.ProviderDomain)) + sender := ocmuser.FormatOCMUser(ocmuser.LocalUserFederatedID(user.Id, s.conf.ProviderDomain)) newShareReq := &client.NewShareRequest{ ShareWith: shareWith, diff --git a/internal/http/services/ocmd/invites.go b/internal/http/services/ocmd/invites.go index 1fda2059fd..fbba971d3c 100644 --- a/internal/http/services/ocmd/invites.go +++ b/internal/http/services/ocmd/invites.go @@ -60,7 +60,7 @@ type acceptInviteRequest struct { Email string `json:"email"` } -// AcceptInvite informs avout an accepted invitation so that the users +// AcceptInvite informs about an accepted invitation so that the users // can initiate the OCM share creation. func (h *invitesHandler) AcceptInvite(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -73,7 +73,7 @@ func (h *invitesHandler) AcceptInvite(w http.ResponseWriter, r *http.Request) { } if req.Token == "" || req.UserID == "" || req.RecipientProvider == "" { - reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "token, userID and recipiendProvider must not be null", nil) + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "token, userID and recipientProvider must not be null", nil) return } @@ -146,7 +146,7 @@ func (h *invitesHandler) AcceptInvite(w http.ResponseWriter, r *http.Request) { } if err := json.NewEncoder(w).Encode(&user{ - UserID: ocmuser.FederatedID(acceptInviteResponse.UserId, "").GetOpaqueId(), + UserID: ocmuser.LocalUserFederatedID(acceptInviteResponse.UserId, "").GetOpaqueId(), Email: acceptInviteResponse.Email, Name: acceptInviteResponse.DisplayName, }); err != nil { diff --git a/internal/http/services/ocmd/shares.go b/internal/http/services/ocmd/shares.go index cb7de98ae6..ee0e5752f9 100644 --- a/internal/http/services/ocmd/shares.go +++ b/internal/http/services/ocmd/shares.go @@ -124,7 +124,7 @@ func (h *sharesHandler) CreateShare(w http.ResponseWriter, r *http.Request) { return } - shareWith, _, err := getIDAndMeshProvider(req.ShareWith) + shareWith, _, err := getLocalUserID(req.ShareWith) if err != nil { reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, err.Error(), nil) return @@ -194,6 +194,22 @@ func (h *sharesHandler) CreateShare(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } +func getLocalUserID(user string) (id, provider string, err error) { + idPart, provider, err := getIDAndMeshProvider(user) + if err != nil { + return "", "", err + } + + // Handle nested @ in idPart (e.g. "user@idp@provider") + if inner := strings.LastIndex(idPart, "@"); inner != -1 { + id = idPart[:inner] + } else { + id = idPart + } + + return id, provider, nil +} + func getUserIDFromOCMUser(user string) (*userpb.UserId, error) { id, idp, err := getIDAndMeshProvider(user) if err != nil { @@ -207,13 +223,12 @@ func getUserIDFromOCMUser(user string) (*userpb.UserId, error) { }, nil } -func getIDAndMeshProvider(user string) (string, string, error) { - // the user is in the form of dimitri@apiwise.nl - split := strings.Split(user, "@") - if len(split) < 2 { +func getIDAndMeshProvider(user string) (id, provider string, err error) { + last := strings.LastIndex(user, "@") + if last == -1 { return "", "", errors.New("not in the form @") } - return strings.Join(split[:len(split)-1], "@"), split[len(split)-1], nil + return user[:last], user[last+1:], nil } func getCreateShareRequest(r *http.Request) (*createShareRequest, error) { diff --git a/pkg/ocm/user/user.go b/pkg/ocm/user/user.go index 9be8add2b4..cf2d26881f 100644 --- a/pkg/ocm/user/user.go +++ b/pkg/ocm/user/user.go @@ -3,16 +3,16 @@ package user import ( "encoding/base64" "fmt" + "net/url" "strings" userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" ) -// FederatedID creates a federated user id by +// LocalUserFederatedID creates a federated id for local users by // 1. stripping the protocol from the domain and -// 2. base64 encoding the opaque id with the domain to get a unique identifier that cannot collide with other users -func FederatedID(id *userpb.UserId, domain string) *userpb.UserId { - opaqueId := base64.URLEncoding.EncodeToString([]byte(id.OpaqueId + "@" + id.Idp)) +func LocalUserFederatedID(id *userpb.UserId, domain string) *userpb.UserId { + opaqueId := id.OpaqueId + "@" + id.Idp return &userpb.UserId{ Type: userpb.UserType_USER_TYPE_FEDERATED, Idp: domain, @@ -20,10 +20,26 @@ func FederatedID(id *userpb.UserId, domain string) *userpb.UserId { } } -// RemoteID creates a remote user id by +// EncodeRemoteUserFederatedID encodes a federated id for remote users by +// 1. stripping the protocol from the domain and +// 2. base64 encoding the opaque id with the domain to get a unique identifier that cannot collide with other users +func EncodeRemoteUserFederatedID(id *userpb.UserId) *userpb.UserId { + // strip protocol from the domain + domain := id.Idp + if u, err := url.Parse(domain); err == nil && u.Host != "" { + domain = u.Host + } + return &userpb.UserId{ + Type: userpb.UserType_USER_TYPE_FEDERATED, + Idp: domain, + OpaqueId: base64.URLEncoding.EncodeToString([]byte(id.OpaqueId + "@" + domain)), + } +} + +// DecodeRemoteUserFederatedID decodes opaque id into remote user's federated id by // 1. decoding the base64 encoded opaque id // 2. splitting the opaque id at the last @ to get the opaque id and the domain -func RemoteID(id *userpb.UserId) *userpb.UserId { +func DecodeRemoteUserFederatedID(id *userpb.UserId) *userpb.UserId { remoteId := &userpb.UserId{ Type: userpb.UserType_USER_TYPE_PRIMARY, Idp: id.Idp, diff --git a/tests/integration/grpc/ocm_invitation_test.go b/tests/integration/grpc/ocm_invitation_test.go index a4e44ab5c2..62e218af47 100644 --- a/tests/integration/grpc/ocm_invitation_test.go +++ b/tests/integration/grpc/ocm_invitation_test.go @@ -21,7 +21,6 @@ package grpc_test import ( "bytes" "context" - "encoding/base64" "encoding/json" "fmt" "net/http" @@ -119,7 +118,7 @@ var _ = Describe("ocm invitation workflow", func() { Id: &userpb.UserId{ Type: userpb.UserType_USER_TYPE_FEDERATED, Idp: "cernbox.cern.ch", - OpaqueId: base64.URLEncoding.EncodeToString([]byte("4c510ada-c86b-4815-8820-42cdf82c3d51@https://cernbox.cern.ch")), + OpaqueId: "4c510ada-c86b-4815-8820-42cdf82c3d51@https://cernbox.cern.ch", }, Username: "einstein", Mail: "einstein@cern.ch", @@ -139,7 +138,7 @@ var _ = Describe("ocm invitation workflow", func() { Id: &userpb.UserId{ Type: userpb.UserType_USER_TYPE_FEDERATED, Idp: "cesnet.cz", - OpaqueId: base64.URLEncoding.EncodeToString([]byte("f7fbf8c8-139b-4376-b307-cf0a8c2d0d9c@https://cesnet.cz")), + OpaqueId: "f7fbf8c8-139b-4376-b307-cf0a8c2d0d9c@https://cesnet.cz", }, Username: "marie", Mail: "marie@cesnet.cz", diff --git a/tests/integration/grpc/ocm_share_test.go b/tests/integration/grpc/ocm_share_test.go index 6075d7d223..9b336acd38 100644 --- a/tests/integration/grpc/ocm_share_test.go +++ b/tests/integration/grpc/ocm_share_test.go @@ -21,7 +21,6 @@ package grpc_test import ( "bytes" "context" - "encoding/base64" "io" "net/http" "path/filepath" @@ -37,6 +36,7 @@ import ( storagep "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" typespb "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" "github.com/cs3org/reva/v2/internal/http/services/datagateway" + "github.com/cs3org/reva/v2/internal/http/services/owncloud/ocdav/net" "github.com/cs3org/reva/v2/pkg/conversions" ctxpkg "github.com/cs3org/reva/v2/pkg/ctx" "github.com/cs3org/reva/v2/pkg/ocm/share" @@ -46,7 +46,6 @@ import ( "github.com/cs3org/reva/v2/pkg/storage/fs/ocis" jwt "github.com/cs3org/reva/v2/pkg/token/manager/jwt" "github.com/cs3org/reva/v2/tests/helpers" - "github.com/owncloud/ocis/v2/services/webdav/pkg/net" "github.com/pkg/errors" "github.com/studio-b12/gowebdav" "google.golang.org/grpc/metadata" @@ -127,7 +126,7 @@ var _ = Describe("ocm share", func() { federatedEinsteinID = &userpb.UserId{ Type: userpb.UserType_USER_TYPE_FEDERATED, Idp: "cernbox.cern.ch", - OpaqueId: base64.URLEncoding.EncodeToString([]byte("4c510ada-c86b-4815-8820-42cdf82c3d51@https://cernbox.cern.ch")), + OpaqueId: "4c510ada-c86b-4815-8820-42cdf82c3d51@cernbox.cern.ch", } marie = &userpb.User{ Id: &userpb.UserId{ @@ -142,7 +141,7 @@ var _ = Describe("ocm share", func() { federatedMarieID = &userpb.UserId{ Type: userpb.UserType_USER_TYPE_FEDERATED, Idp: "cesnet.cz", - OpaqueId: base64.URLEncoding.EncodeToString([]byte("f7fbf8c8-139b-4376-b307-cf0a8c2d0d9c@https://cesnet.cz")), + OpaqueId: "f7fbf8c8-139b-4376-b307-cf0a8c2d0d9c@cesnet.cz", } ) @@ -215,7 +214,7 @@ var _ = Describe("ocm share", func() { Expect(invRes.UserId.Type).To(Equal(userpb.UserType_USER_TYPE_FEDERATED)) // Federated users use the OCM provider id which MUST NOT contain the protocol Expect(invRes.UserId.Idp).To(Equal("cernbox.cern.ch")) - // The OpaqueId is the base64 encoded user id and the provider id to provent collisions with other users on the graph API + // The OpaqueId is the base64 encoded user id and the provider id to prevent collisions with other users on the graph API Expect(invRes.UserId.OpaqueId).To(Equal(federatedEinsteinID.OpaqueId)) }) From 0a01ff0e893426df2cea0c63b037deb3135475fa Mon Sep 17 00:00:00 2001 From: Roman Perekhod <2403905@gmail.com> Date: Fri, 17 Oct 2025 18:33:53 +0200 Subject: [PATCH 03/15] fix: undefined ocmuser.RemoteID --- internal/grpc/services/ocmcore/ocmcore.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/grpc/services/ocmcore/ocmcore.go b/internal/grpc/services/ocmcore/ocmcore.go index ffd1289867..6e0e116a16 100644 --- a/internal/grpc/services/ocmcore/ocmcore.go +++ b/internal/grpc/services/ocmcore/ocmcore.go @@ -158,7 +158,7 @@ func (s *service) UpdateOCMCoreShare(ctx context.Context, req *ocmcore.UpdateOCM } fileMask := &fieldmaskpb.FieldMask{Paths: []string{"protocols"}} - user := &userpb.User{Id: ocmuser.RemoteID(&userpb.UserId{OpaqueId: grantee})} + user := &userpb.User{Id: ocmuser.DecodeRemoteUserFederatedID(&userpb.UserId{OpaqueId: grantee})} _, err := s.repo.UpdateReceivedShare(ctx, user, &ocm.ReceivedShare{ Id: &ocm.ShareId{ OpaqueId: req.GetOcmShareId(), @@ -185,7 +185,7 @@ func (s *service) DeleteOCMCoreShare(ctx context.Context, req *ocmcore.DeleteOCM return nil, errtypes.UserRequired("missing remote user id in a metadata") } - user := &userpb.User{Id: ocmuser.RemoteID(&userpb.UserId{OpaqueId: grantee})} + user := &userpb.User{Id: ocmuser.DecodeRemoteUserFederatedID(&userpb.UserId{OpaqueId: grantee})} err := s.repo.DeleteReceivedShare(ctx, user, &ocm.ShareReference{ Spec: &ocm.ShareReference_Id{ From 970caa5eebb368993085ea84cd919b790fd9e10c Mon Sep 17 00:00:00 2001 From: Roman Perekhod <2403905@gmail.com> Date: Fri, 17 Oct 2025 10:55:25 +0200 Subject: [PATCH 04/15] fix: always allow providers --- pkg/ocm/provider/authorizer/json/json.go | 6 ++++-- pkg/ocm/provider/authorizer/json/json_test.go | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pkg/ocm/provider/authorizer/json/json.go b/pkg/ocm/provider/authorizer/json/json.go index 152140fdba..5a8bf296a9 100644 --- a/pkg/ocm/provider/authorizer/json/json.go +++ b/pkg/ocm/provider/authorizer/json/json.go @@ -173,10 +173,12 @@ func (a *authorizer) IsProviderAllowed(ctx context.Context, pi *ocmprovider.Prov } switch { - case !providerAuthorized: - return errtypes.NotFound(pi.GetDomain()) case !a.conf.VerifyRequestHostname: + log.Info().Msg("VerifyRequestHostname is disabled. any provider is allowed") return nil + case !providerAuthorized: + log.Info().Msg("providerAuthorized is false") + return errtypes.NotFound(pi.GetDomain()) case len(pi.Services) == 0: return ErrNoIP } diff --git a/pkg/ocm/provider/authorizer/json/json_test.go b/pkg/ocm/provider/authorizer/json/json_test.go index 52c525733b..b6722f23d8 100644 --- a/pkg/ocm/provider/authorizer/json/json_test.go +++ b/pkg/ocm/provider/authorizer/json/json_test.go @@ -49,7 +49,8 @@ func TestAuthorizer_IsProviderAllowed(t *testing.T) { providerInfo: &ocmprovider.ProviderInfo{ Domain: "some.unknown.domain", }, - expectedError: errtypes.NotFound("some.unknown.domain"), + verifyRequestHostname: true, + expectedError: errtypes.NotFound("some.unknown.domain"), }, "authorized without host name verification": { providerInfo: &ocmprovider.ProviderInfo{ @@ -65,7 +66,8 @@ func TestAuthorizer_IsProviderAllowed(t *testing.T) { providerInfo: &ocmprovider.ProviderInfo{ Domain: "server-two", }, - expectedError: error(errtypes.NotFound("server-two")), + verifyRequestHostname: true, + expectedError: error(errtypes.NotFound("server-two")), }, } { t.Run(name, func(t *testing.T) { From 0975838541684ff291b98d5eb7b5263c6bccd86b Mon Sep 17 00:00:00 2001 From: Roman Perekhod <2403905@gmail.com> Date: Tue, 21 Oct 2025 10:00:10 +0200 Subject: [PATCH 05/15] fix: get rid of base64 --- .../ocminvitemanager/ocminvitemanager.go | 4 +- .../ocmshareprovider/ocmshareprovider.go | 2 +- pkg/ocm/user/user.go | 38 ++++++++----------- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/internal/grpc/services/ocminvitemanager/ocminvitemanager.go b/internal/grpc/services/ocminvitemanager/ocminvitemanager.go index cb44d1a9c6..13b99b989a 100644 --- a/internal/grpc/services/ocminvitemanager/ocminvitemanager.go +++ b/internal/grpc/services/ocminvitemanager/ocminvitemanager.go @@ -224,7 +224,7 @@ func (s *service) ForwardInvite(ctx context.Context, req *invitepb.ForwardInvite } // we need to use a unique identifier for federated users - remoteUserID = ocmuser.EncodeRemoteUserFederatedID(remoteUserID) + remoteUserID = ocmuser.LocalUserFederatedID(remoteUserID, "") if err := s.repo.AddRemoteUser(ctx, user.Id, &userpb.User{ Id: remoteUserID, @@ -286,7 +286,7 @@ func (s *service) AcceptInvite(ctx context.Context, req *invitepb.AcceptInviteRe remoteUser := req.GetRemoteUser() // we need to use a unique identifier for federated users - remoteUser.Id = ocmuser.EncodeRemoteUserFederatedID(remoteUser.Id) + remoteUser.Id = ocmuser.LocalUserFederatedID(remoteUser.Id, "") if err := s.repo.AddRemoteUser(ctx, token.GetUserId(), remoteUser); err != nil { if !errors.Is(err, invite.ErrUserAlreadyAccepted) { diff --git a/internal/grpc/services/ocmshareprovider/ocmshareprovider.go b/internal/grpc/services/ocmshareprovider/ocmshareprovider.go index 26ee674aa7..b0ed5c0ff0 100644 --- a/internal/grpc/services/ocmshareprovider/ocmshareprovider.go +++ b/internal/grpc/services/ocmshareprovider/ocmshareprovider.go @@ -325,7 +325,7 @@ func (s *service) CreateOCMShare(ctx context.Context, req *ocm.CreateOCMShareReq // 2.b replace outgoing user ids with ocm user ids // unpack the federated user id - shareWith := ocmuser.FormatOCMUser(ocmuser.DecodeRemoteUserFederatedID(req.GetGrantee().GetUserId())) + shareWith := ocmuser.FormatOCMUser(ocmuser.LocalUserFederatedID(req.GetGrantee().GetUserId(), "")) // wrap the local user id in a local federated user id owner := ocmuser.FormatOCMUser(ocmuser.LocalUserFederatedID(info.Owner, s.conf.ProviderDomain)) diff --git a/pkg/ocm/user/user.go b/pkg/ocm/user/user.go index cf2d26881f..8fb6756369 100644 --- a/pkg/ocm/user/user.go +++ b/pkg/ocm/user/user.go @@ -1,7 +1,6 @@ package user import ( - "encoding/base64" "fmt" "net/url" "strings" @@ -11,29 +10,23 @@ import ( // LocalUserFederatedID creates a federated id for local users by // 1. stripping the protocol from the domain and +// 2. if the domain is different from the idp, add the idp to the opaque id func LocalUserFederatedID(id *userpb.UserId, domain string) *userpb.UserId { - opaqueId := id.OpaqueId + "@" + id.Idp - return &userpb.UserId{ - Type: userpb.UserType_USER_TYPE_FEDERATED, - Idp: domain, - OpaqueId: opaqueId, - } -} - -// EncodeRemoteUserFederatedID encodes a federated id for remote users by -// 1. stripping the protocol from the domain and -// 2. base64 encoding the opaque id with the domain to get a unique identifier that cannot collide with other users -func EncodeRemoteUserFederatedID(id *userpb.UserId) *userpb.UserId { - // strip protocol from the domain - domain := id.Idp if u, err := url.Parse(domain); err == nil && u.Host != "" { domain = u.Host } - return &userpb.UserId{ + + u := &userpb.UserId{ Type: userpb.UserType_USER_TYPE_FEDERATED, - Idp: domain, - OpaqueId: base64.URLEncoding.EncodeToString([]byte(id.OpaqueId + "@" + domain)), + Idp: id.Idp, + OpaqueId: id.OpaqueId, + } + + if id.Idp != "" && domain != "" && id.Idp != domain { + u.OpaqueId = id.OpaqueId + "@" + id.Idp + u.Idp = domain } + return u } // DecodeRemoteUserFederatedID decodes opaque id into remote user's federated id by @@ -45,11 +38,7 @@ func DecodeRemoteUserFederatedID(id *userpb.UserId) *userpb.UserId { Idp: id.Idp, OpaqueId: id.OpaqueId, } - bytes, err := base64.URLEncoding.DecodeString(id.GetOpaqueId()) - if err != nil { - return remoteId - } - remote := string(bytes) + remote := id.OpaqueId last := strings.LastIndex(remote, "@") if last == -1 { return remoteId @@ -62,5 +51,8 @@ func DecodeRemoteUserFederatedID(id *userpb.UserId) *userpb.UserId { // FormatOCMUser formats a user id in the form of @ used by the OCM API in shareWith, owner and creator fields func FormatOCMUser(u *userpb.UserId) string { + if u.Idp == "" { + return u.OpaqueId + } return fmt.Sprintf("%s@%s", u.OpaqueId, u.Idp) } From 91592ba3e64c2f65367805a5dda8eecbc2278784 Mon Sep 17 00:00:00 2001 From: Roman Perekhod <2403905@gmail.com> Date: Thu, 23 Oct 2025 23:43:37 +0200 Subject: [PATCH 06/15] fix: remove redundant conversion --- .../ocminvitemanager/ocminvitemanager.go | 8 +++---- .../ocmshareprovider/ocmshareprovider.go | 6 +++--- internal/http/services/ocmd/invites.go | 2 +- internal/http/services/ocmd/shares.go | 14 +++++++++++-- pkg/ocm/invite/repository/json/json.go | 12 ++++++----- pkg/ocm/user/user.go | 21 ++++++++----------- 6 files changed, 36 insertions(+), 27 deletions(-) diff --git a/internal/grpc/services/ocminvitemanager/ocminvitemanager.go b/internal/grpc/services/ocminvitemanager/ocminvitemanager.go index 13b99b989a..0da39b5612 100644 --- a/internal/grpc/services/ocminvitemanager/ocminvitemanager.go +++ b/internal/grpc/services/ocminvitemanager/ocminvitemanager.go @@ -180,9 +180,9 @@ func (s *service) ForwardInvite(ctx context.Context, req *invitepb.ForwardInvite remoteUser, err := s.ocmClient.InviteAccepted(ctx, ocmEndpoint, &client.InviteAcceptedRequest{ Token: req.InviteToken.GetToken(), RecipientProvider: s.conf.ProviderDomain, - // The UserID is only a string here. To not lose the IDP information we use the LocalUserFederatedID encoding + // The UserID is only a string here. To not lose the IDP information we use the FederatedID encoding // i.e. UserID@IDP - UserID: ocmuser.LocalUserFederatedID(user.GetId(), "").GetOpaqueId(), + UserID: ocmuser.FederatedID(user.GetId(), "").GetOpaqueId(), Email: user.GetMail(), Name: user.GetDisplayName(), }) @@ -224,7 +224,7 @@ func (s *service) ForwardInvite(ctx context.Context, req *invitepb.ForwardInvite } // we need to use a unique identifier for federated users - remoteUserID = ocmuser.LocalUserFederatedID(remoteUserID, "") + // remoteUserID = ocmuser.FederatedID(remoteUserID, "") if err := s.repo.AddRemoteUser(ctx, user.Id, &userpb.User{ Id: remoteUserID, @@ -286,7 +286,7 @@ func (s *service) AcceptInvite(ctx context.Context, req *invitepb.AcceptInviteRe remoteUser := req.GetRemoteUser() // we need to use a unique identifier for federated users - remoteUser.Id = ocmuser.LocalUserFederatedID(remoteUser.Id, "") + //remoteUser.Id = ocmuser.FederatedID(remoteUser.Id, "") if err := s.repo.AddRemoteUser(ctx, token.GetUserId(), remoteUser); err != nil { if !errors.Is(err, invite.ErrUserAlreadyAccepted) { diff --git a/internal/grpc/services/ocmshareprovider/ocmshareprovider.go b/internal/grpc/services/ocmshareprovider/ocmshareprovider.go index b0ed5c0ff0..3467e36dd5 100644 --- a/internal/grpc/services/ocmshareprovider/ocmshareprovider.go +++ b/internal/grpc/services/ocmshareprovider/ocmshareprovider.go @@ -325,11 +325,11 @@ func (s *service) CreateOCMShare(ctx context.Context, req *ocm.CreateOCMShareReq // 2.b replace outgoing user ids with ocm user ids // unpack the federated user id - shareWith := ocmuser.FormatOCMUser(ocmuser.LocalUserFederatedID(req.GetGrantee().GetUserId(), "")) + shareWith := ocmuser.FormatOCMUser(ocmuser.FederatedID(req.GetGrantee().GetUserId(), "")) // wrap the local user id in a local federated user id - owner := ocmuser.FormatOCMUser(ocmuser.LocalUserFederatedID(info.Owner, s.conf.ProviderDomain)) - sender := ocmuser.FormatOCMUser(ocmuser.LocalUserFederatedID(user.Id, s.conf.ProviderDomain)) + owner := ocmuser.FormatOCMUser(ocmuser.FederatedID(info.Owner, s.conf.ProviderDomain)) + sender := ocmuser.FormatOCMUser(ocmuser.FederatedID(user.Id, s.conf.ProviderDomain)) newShareReq := &client.NewShareRequest{ ShareWith: shareWith, diff --git a/internal/http/services/ocmd/invites.go b/internal/http/services/ocmd/invites.go index fbba971d3c..3989665bd5 100644 --- a/internal/http/services/ocmd/invites.go +++ b/internal/http/services/ocmd/invites.go @@ -146,7 +146,7 @@ func (h *invitesHandler) AcceptInvite(w http.ResponseWriter, r *http.Request) { } if err := json.NewEncoder(w).Encode(&user{ - UserID: ocmuser.LocalUserFederatedID(acceptInviteResponse.UserId, "").GetOpaqueId(), + UserID: ocmuser.FederatedID(acceptInviteResponse.UserId, "").GetOpaqueId(), Email: acceptInviteResponse.Email, Name: acceptInviteResponse.DisplayName, }); err != nil { diff --git a/internal/http/services/ocmd/shares.go b/internal/http/services/ocmd/shares.go index ee0e5752f9..65e5d6d9ca 100644 --- a/internal/http/services/ocmd/shares.go +++ b/internal/http/services/ocmd/shares.go @@ -215,6 +215,7 @@ func getUserIDFromOCMUser(user string) (*userpb.UserId, error) { if err != nil { return nil, err } + idp = strings.TrimPrefix(idp, "https://") // strip off leading scheme if present (despite being not OCM compliant). This is the case in Nextcloud and oCIS return &userpb.UserId{ OpaqueId: id, Idp: idp, @@ -226,9 +227,18 @@ func getUserIDFromOCMUser(user string) (*userpb.UserId, error) { func getIDAndMeshProvider(user string) (id, provider string, err error) { last := strings.LastIndex(user, "@") if last == -1 { - return "", "", errors.New("not in the form @") + return "", "", fmt.Errorf("%s not in the form @", user) } - return user[:last], user[last+1:], nil + + id, provider = user[:last], user[last+1:] + if id == "" { + return "", "", errors.New("id cannot be empty") + } + if provider == "" { + return "", "", errors.New("provider cannot be empty") + } + + return id, provider, nil } func getCreateShareRequest(r *http.Request) (*createShareRequest, error) { diff --git a/pkg/ocm/invite/repository/json/json.go b/pkg/ocm/invite/repository/json/json.go index 9fd92b0efb..d7e42ae0f4 100644 --- a/pkg/ocm/invite/repository/json/json.go +++ b/pkg/ocm/invite/repository/json/json.go @@ -226,12 +226,14 @@ func (m *manager) GetRemoteUser(ctx context.Context, initiator *userpb.UserId, r log := appctx.GetLogger(ctx) for _, acceptedUser := range m.model.AcceptedUsers[initiator.GetOpaqueId()] { log.Info().Msgf("looking for '%s' at '%s' - considering '%s' at '%s'", - remoteUserID.OpaqueId, - remoteUserID.Idp, - acceptedUser.Id.GetOpaqueId(), - acceptedUser.Id.GetIdp(), - ) + remoteUserID.OpaqueId, remoteUserID.Idp, + acceptedUser.Id.GetOpaqueId(), acceptedUser.Id.GetIdp()) + if (acceptedUser.Id.GetOpaqueId() == remoteUserID.OpaqueId) && (remoteUserID.Idp == "" || idpsEqual(acceptedUser.Id.GetIdp(), remoteUserID.Idp)) { + log.Info().Msgf("remote user OpaqueId:'%s' Idp:'%s' matches OpaqueId:'%s' Idp:'%s'", + remoteUserID.OpaqueId, remoteUserID.Idp, + acceptedUser.Id.GetOpaqueId(), acceptedUser.Id.GetIdp()) + return acceptedUser, nil } } diff --git a/pkg/ocm/user/user.go b/pkg/ocm/user/user.go index 8fb6756369..999de68fc6 100644 --- a/pkg/ocm/user/user.go +++ b/pkg/ocm/user/user.go @@ -2,30 +2,27 @@ package user import ( "fmt" - "net/url" "strings" userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" ) -// LocalUserFederatedID creates a federated id for local users by +// FederatedID creates a federated id for local users by // 1. stripping the protocol from the domain and // 2. if the domain is different from the idp, add the idp to the opaque id -func LocalUserFederatedID(id *userpb.UserId, domain string) *userpb.UserId { - if u, err := url.Parse(domain); err == nil && u.Host != "" { - domain = u.Host +func FederatedID(id *userpb.UserId, domain string) *userpb.UserId { + opaqueId := id.OpaqueId + if !strings.Contains(id.OpaqueId, "@") { + opaqueId = id.OpaqueId + "@" + id.Idp } u := &userpb.UserId{ - Type: userpb.UserType_USER_TYPE_FEDERATED, - Idp: id.Idp, - OpaqueId: id.OpaqueId, + Type: userpb.UserType_USER_TYPE_FEDERATED, + // Idp: id.Idp + Idp: domain, + OpaqueId: opaqueId, } - if id.Idp != "" && domain != "" && id.Idp != domain { - u.OpaqueId = id.OpaqueId + "@" + id.Idp - u.Idp = domain - } return u } From f8f3a5bfc4ededd75d8370276a4202d698fb0465 Mon Sep 17 00:00:00 2001 From: Roman Perekhod <2403905@gmail.com> Date: Thu, 30 Oct 2025 10:50:16 +0100 Subject: [PATCH 07/15] fix: [OCISDEV-464] Aligning the OCM --- pkg/ocm/user/user.go | 23 +++++++++---- pkg/ocm/user/user_test.go | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 pkg/ocm/user/user_test.go diff --git a/pkg/ocm/user/user.go b/pkg/ocm/user/user.go index 999de68fc6..4644fa3b93 100644 --- a/pkg/ocm/user/user.go +++ b/pkg/ocm/user/user.go @@ -2,6 +2,7 @@ package user import ( "fmt" + "net/url" "strings" userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" @@ -9,16 +10,20 @@ import ( // FederatedID creates a federated id for local users by // 1. stripping the protocol from the domain and -// 2. if the domain is different from the idp, add the idp to the opaque id +// 2. concatenating the opaque id with the domain to get a unique identifier that cannot collide with other users func FederatedID(id *userpb.UserId, domain string) *userpb.UserId { + // strip protocol from the domain + idp := id.Idp + if u, err := url.Parse(id.Idp); err == nil && u.Host != "" { + idp = u.Host + } opaqueId := id.OpaqueId if !strings.Contains(id.OpaqueId, "@") { - opaqueId = id.OpaqueId + "@" + id.Idp + opaqueId = id.OpaqueId + "@" + idp } u := &userpb.UserId{ - Type: userpb.UserType_USER_TYPE_FEDERATED, - // Idp: id.Idp + Type: userpb.UserType_USER_TYPE_FEDERATED, Idp: domain, OpaqueId: opaqueId, } @@ -27,8 +32,7 @@ func FederatedID(id *userpb.UserId, domain string) *userpb.UserId { } // DecodeRemoteUserFederatedID decodes opaque id into remote user's federated id by -// 1. decoding the base64 encoded opaque id -// 2. splitting the opaque id at the last @ to get the opaque id and the domain +// splitting the opaque id at the last @ to get the opaque id and the domain func DecodeRemoteUserFederatedID(id *userpb.UserId) *userpb.UserId { remoteId := &userpb.UserId{ Type: userpb.UserType_USER_TYPE_PRIMARY, @@ -51,5 +55,10 @@ func FormatOCMUser(u *userpb.UserId) string { if u.Idp == "" { return u.OpaqueId } - return fmt.Sprintf("%s@%s", u.OpaqueId, u.Idp) + // strip protocol from the domain + idp := u.Idp + if u, err := url.Parse(u.Idp); err == nil && u.Host != "" { + idp = u.Host + } + return fmt.Sprintf("%s@%s", u.OpaqueId, idp) } diff --git a/pkg/ocm/user/user_test.go b/pkg/ocm/user/user_test.go new file mode 100644 index 0000000000..19ad966aa3 --- /dev/null +++ b/pkg/ocm/user/user_test.go @@ -0,0 +1,71 @@ +package user + +import ( + "testing" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" +) + +func TestLocalUserFederatedID(t *testing.T) { + tests := []struct { + name string + id *userpb.UserId + domain string + wantOpaqueId string + wantIdp string + }{ + { + name: "local same idp", + id: &userpb.UserId{Type: userpb.UserType_USER_TYPE_FEDERATED, Idp: "https://ocis.cloud.io", OpaqueId: "id"}, + domain: "ocis.cloud.io", + wantOpaqueId: "id@ocis.cloud.io", + wantIdp: "ocis.cloud.io", + }, + { + name: "remote different idp", + id: &userpb.UserId{Type: userpb.UserType_USER_TYPE_FEDERATED, Idp: "https://ocis.cloud.io", OpaqueId: "id"}, + domain: "idp.cloud.io", + wantOpaqueId: "id@ocis.cloud.io", + wantIdp: "idp.cloud.io", + }, + { + name: "opaque contains idp, protocol in domain", + id: &userpb.UserId{Type: userpb.UserType_USER_TYPE_FEDERATED, Idp: "https://ocis.cloud.io", OpaqueId: "id"}, + domain: "https://ocis.cloud.io", + wantOpaqueId: "id@ocis.cloud.io", + wantIdp: "https://ocis.cloud.io", + }, + { + name: "opaque contains idp, protocol in domain", + id: &userpb.UserId{Type: userpb.UserType_USER_TYPE_FEDERATED, Idp: "https://ocis.cloud.io", OpaqueId: "id"}, + domain: "https://ocis.cloud.io", + wantOpaqueId: "id@ocis.cloud.io", + wantIdp: "https://ocis.cloud.io", + }, + { + name: "opaque contains idp, protocol in domain", + id: &userpb.UserId{Type: userpb.UserType_USER_TYPE_FEDERATED, Idp: "ocis.cloud.io", OpaqueId: "id"}, + domain: "", + wantOpaqueId: "id@ocis.cloud.io", + wantIdp: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FederatedID(tt.id, tt.domain) + if got == nil { + t.Fatalf("got=nil") + } + if got.Type != userpb.UserType_USER_TYPE_FEDERATED { + t.Fatalf("type=%v want=%v", got.Type, userpb.UserType_USER_TYPE_FEDERATED) + } + if got.OpaqueId != tt.wantOpaqueId { + t.Fatalf("OpaqueId got=%q want=%q", got.OpaqueId, tt.wantOpaqueId) + } + if got.Idp != tt.wantIdp { + t.Fatalf("Idp got=%q want=%q", got.Idp, tt.wantIdp) + } + }) + } +} From 5392c798a4cc3bbb6b93cbf780bfd69b1b4189ab Mon Sep 17 00:00:00 2001 From: Roman Perekhod <2403905@gmail.com> Date: Thu, 30 Oct 2025 19:11:07 +0100 Subject: [PATCH 08/15] fix: [OCISDEV-462] fix the gowebdav --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 292bce317d..945c02817b 100644 --- a/go.mod +++ b/go.mod @@ -221,7 +221,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/studio-b12/gowebdav => github.com/kobergj/gowebdav v0.0.0-20250102091030-aa65266db202 +replace github.com/studio-b12/gowebdav => github.com/kobergj/gowebdav v0.0.0-20251030165916-532350997dde replace github.com/go-micro/plugins/v4/store/nats-js-kv => github.com/kobergj/plugins/v4/store/nats-js-kv v0.0.0-20240807130109-f62bb67e8c90 diff --git a/go.sum b/go.sum index 95e5ae55ee..5c4cc6bcd0 100644 --- a/go.sum +++ b/go.sum @@ -420,8 +420,8 @@ github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kobergj/go-micro/v4 v4.0.0-20250117084952-d07d30666b7c h1:21N6rhk5dzUxYhJYiBsyqoRrIowpetooAS5mDwHayeE= github.com/kobergj/go-micro/v4 v4.0.0-20250117084952-d07d30666b7c/go.mod h1:eE/tD53n3KbVrzrWxKLxdkGw45Fg1qaNLWjpJMvIUF4= -github.com/kobergj/gowebdav v0.0.0-20250102091030-aa65266db202 h1:A1xJ2NKgiYFiaHiLl9B5yw/gUBACSs9crDykTS3GuQI= -github.com/kobergj/gowebdav v0.0.0-20250102091030-aa65266db202/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= +github.com/kobergj/gowebdav v0.0.0-20251030165916-532350997dde h1:HYcp4J4xYe2m9KSUVbTccJb14TpSs+ldCfDFgqsXedI= +github.com/kobergj/gowebdav v0.0.0-20251030165916-532350997dde/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= github.com/kobergj/plugins/v4/store/nats-js-kv v0.0.0-20240807130109-f62bb67e8c90 h1:pfI8Z5yavO6fU6vDGlWhZ4BgDlvj8c6xB7J57HfTPwA= github.com/kobergj/plugins/v4/store/nats-js-kv v0.0.0-20240807130109-f62bb67e8c90/go.mod h1:pjcozWijkNPbEtX5SIQaxEW/h8VAVZYTLx+70bmB3LY= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= From c7b1ee0e53c1c6467dba04e4eccfb4ed84a5b0e1 Mon Sep 17 00:00:00 2001 From: Mahdi Baghbani Date: Sun, 26 Oct 2025 09:00:55 +0000 Subject: [PATCH 09/15] feat(ocm): add wayf specific /discover and /federations endpoints to sciencemesh package update(ocm): OCM discovery to 1.2 feat(ocm): InviteAcceptDialog to OCM Discovery feat(ocm): add new ocm client to ocmd package for active discovery add: federations.json example add: changelog Signed-off-by: Mahdi Baghbani --- changelog/unreleased/add-ocm-wayf.md | 7 + examples/ocmd/federations.demo.json | 29 ++ internal/http/services/ocmd/client.go | 111 ++++++++ .../http/services/sciencemesh/sciencemesh.go | 13 +- internal/http/services/sciencemesh/wayf.go | 256 ++++++++++++++++++ internal/http/services/wellknown/ocm.go | 40 +-- 6 files changed, 438 insertions(+), 18 deletions(-) create mode 100644 changelog/unreleased/add-ocm-wayf.md create mode 100644 examples/ocmd/federations.demo.json create mode 100644 internal/http/services/ocmd/client.go create mode 100644 internal/http/services/sciencemesh/wayf.go diff --git a/changelog/unreleased/add-ocm-wayf.md b/changelog/unreleased/add-ocm-wayf.md new file mode 100644 index 0000000000..9eb6562221 --- /dev/null +++ b/changelog/unreleased/add-ocm-wayf.md @@ -0,0 +1,7 @@ +Enhancement: Add OCM Where Are You From capability + +Implements WAYF specific discovery endpoints for the ScienceMesh package, +enabling dynamic OCM provider discovery and federation management. +The implementation follows the OCM Discovery 1.2 specification. + +https://github.com/owncloud/reva/pull/432 diff --git a/examples/ocmd/federations.demo.json b/examples/ocmd/federations.demo.json new file mode 100644 index 0000000000..f960d24f44 --- /dev/null +++ b/examples/ocmd/federations.demo.json @@ -0,0 +1,29 @@ +[ + { + "federation": "EOSC Federation", + "servers": [ + { + "displayName": "CERNBox", + "url": "https://qa.cernbox.cern.ch" + }, + { + "displayName": "ownCloud Demo", + "url": "https://oc.example.org" + } + ] + }, + { + "federation": "Azadeh Afzar Federation", + "servers": [ + { + "displayName": "Tehran Center", + "url": "https://1.ocis.cloud.test.azadehafzar.io" + }, + { + "displayName": "Mahdi Home", + "url": "https://2.ocis.cloud.test.azadehafzar.io" + } + ] + } +] + diff --git a/internal/http/services/ocmd/client.go b/internal/http/services/ocmd/client.go new file mode 100644 index 0000000000..f0ed606028 --- /dev/null +++ b/internal/http/services/ocmd/client.go @@ -0,0 +1,111 @@ +// Copyright 2018-2025 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package ocmd + +import ( + "context" + "crypto/tls" + "encoding/json" + "io" + "net/http" + "net/url" + "time" + + "github.com/cs3org/reva/v2/internal/http/services/wellknown" + "github.com/cs3org/reva/v2/pkg/appctx" + "github.com/cs3org/reva/v2/pkg/errtypes" + "github.com/pkg/errors" +) + +// OCMClient is the client for an OCM provider. +type OCMClient struct { + client *http.Client +} + +// NewClient returns a new OCMClient. +func NewClient(timeout time.Duration, insecure bool) *OCMClient { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, + } + return &OCMClient{ + client: &http.Client{ + Transport: tr, + Timeout: timeout, + }, + } +} + +// Discover returns the OCM discovery information for a remote endpoint. +// It tries /.well-known/ocm first, then falls back to /ocm-provider (legacy). +// https://cs3org.github.io/OCM-API/docs.html?branch=develop&repo=OCM-API&user=cs3org#/paths/~1ocm-provider/get +func (c *OCMClient) Discover(ctx context.Context, endpoint string) (*wellknown.OcmDiscoveryData, error) { + log := appctx.GetLogger(ctx) + + remoteurl, _ := url.JoinPath(endpoint, "/.well-known/ocm") + body, err := c.discover(ctx, remoteurl) + if err != nil || len(body) == 0 { + log.Debug().Err(err).Str("sender", remoteurl).Str("response", string(body)). + Msg("invalid or empty response, falling back to legacy discovery") + remoteurl, _ := url.JoinPath(endpoint, "/ocm-provider") // legacy discovery endpoint + + body, err = c.discover(ctx, remoteurl) + if err != nil || len(body) == 0 { + log.Warn().Err(err).Str("sender", remoteurl).Str("response", string(body)). + Msg("invalid or empty response") + return nil, errtypes.BadRequest("Invalid response on OCM discovery") + } + } + + var disco wellknown.OcmDiscoveryData + err = json.Unmarshal(body, &disco) + if err != nil { + log.Warn().Err(err).Str("sender", remoteurl).Str("response", string(body)). + Msg("malformed response") + return nil, errtypes.BadRequest("Invalid payload on OCM discovery") + } + + log.Debug().Str("sender", remoteurl).Any("response", disco).Msg("discovery response") + return &disco, nil +} + +func (c *OCMClient) discover(ctx context.Context, url string) ([]byte, error) { + log := appctx.GetLogger(ctx) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, errors.Wrap(err, "error creating OCM discovery request") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "error doing OCM discovery request") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Warn().Str("sender", url).Int("status", resp.StatusCode).Msg("discovery returned") + return nil, errtypes.BadRequest("Remote does not offer a valid OCM discovery endpoint") + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "malformed remote OCM discovery") + } + return body, nil +} diff --git a/internal/http/services/sciencemesh/sciencemesh.go b/internal/http/services/sciencemesh/sciencemesh.go index 6eb572d647..a6ae42ce52 100644 --- a/internal/http/services/sciencemesh/sciencemesh.go +++ b/internal/http/services/sciencemesh/sciencemesh.go @@ -65,6 +65,7 @@ type config struct { ProviderDomain string `mapstructure:"provider_domain" validate:"required"` MeshDirectoryURL string `mapstructure:"mesh_directory_url"` OCMMountPoint string `mapstructure:"ocm_mount_point"` + FederationsFile string `mapstructure:"federations_file"` Events EventOptions `mapstructure:"events"` } @@ -86,6 +87,9 @@ func (c *config) ApplyDefaults() { if c.OCMMountPoint == "" { c.OCMMountPoint = "/ocm" } + if c.FederationsFile == "" { + c.FederationsFile = "/etc/revad/federations.json" + } c.GatewaySvc = sharedconf.GetGatewaySVC(c.GatewaySvc) } @@ -114,6 +118,11 @@ func (s *svc) routerInit() error { return err } + wayfHandler := new(wayfHandler) + if err := wayfHandler.init(s.conf); err != nil { + return err + } + s.router.Post("/generate-invite", tokenHandler.Generate) s.router.Get("/list-invite", tokenHandler.ListInvite) s.router.Post("/accept-invite", tokenHandler.AcceptInvite) @@ -122,6 +131,8 @@ func (s *svc) routerInit() error { s.router.Get("/list-providers", providersHandler.ListProviders) s.router.Post("/create-share", sharesHandler.CreateShare) s.router.Post("/open-in-app", appsHandler.OpenInApp) + s.router.Get("/federations", wayfHandler.GetFederations) + s.router.Post("/discover", wayfHandler.DiscoverProvider) return nil } @@ -130,7 +141,7 @@ func (s *svc) Prefix() string { } func (s *svc) Unprotected() []string { - return nil + return []string{"/federations", "/discover"} } func (s *svc) Handler() http.Handler { diff --git a/internal/http/services/sciencemesh/wayf.go b/internal/http/services/sciencemesh/wayf.go new file mode 100644 index 0000000000..196b5c979f --- /dev/null +++ b/internal/http/services/sciencemesh/wayf.go @@ -0,0 +1,256 @@ +// Copyright 2018-2025 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package sciencemesh + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/cs3org/reva/v2/internal/http/services/ocmd" + "github.com/cs3org/reva/v2/internal/http/services/reqres" + "github.com/cs3org/reva/v2/pkg/appctx" +) + +type wayfHandler struct { + federations []Federation +} + +type Federation struct { + Federation string `json:"federation"` + Servers []FederationServer `json:"servers"` +} + +// FederationServer represents a single provider with discovery info +type FederationServer struct { + DisplayName string `json:"displayName"` + URL string `json:"url"` + InviteAcceptDialog string `json:"inviteAcceptDialog,omitempty"` +} + +// federationFile is the on-disk structure without inviteAcceptDialog +type federationFile struct { + Federation string `json:"federation"` + Servers []federationServerFile `json:"servers"` +} + +type federationServerFile struct { + DisplayName string `json:"displayName"` + URL string `json:"url"` +} + +type DiscoverRequest struct { + Domain string `json:"domain"` +} + +type DiscoverResponse struct { + InviteAcceptDialog string `json:"inviteAcceptDialog"` +} + +func (h *wayfHandler) init(c *config) error { + log := appctx.GetLogger(context.Background()) + + log.Debug().Str("file", c.FederationsFile).Msg("Initializing WAYF handler with federations file") + + data, err := os.ReadFile(c.FederationsFile) + if err != nil { + if os.IsNotExist(err) { + log.Warn().Str("file", c.FederationsFile).Msg("Federations file not found, starting with empty list") + h.federations = []Federation{} + return nil + } + log.Error().Err(err).Str("file", c.FederationsFile).Msg("Failed to read federations file") + return err + } + + var fileData []federationFile + if err := json.Unmarshal(data, &fileData); err != nil { + log.Error().Err(err).Str("file", c.FederationsFile).Msg("Failed to parse federations file") + return err + } + + log.Debug().Int("federations_count", len(fileData)).Msg("Loaded federations from file") + + // Create OCM client for discovery + ocmClient := ocmd.NewClient(10*time.Second, false) + log.Debug().Msg("Created OCM client for discovery") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + // Discover each server and populate inviteAcceptDialog + h.federations = []Federation{} + discoveryErrors := 0 + validServersCount := 0 + + for _, fed := range fileData { + log.Debug().Str("federation", fed.Federation).Int("servers_count", len(fed.Servers)).Msg("Processing federation") + validServers := []FederationServer{} + + for _, srv := range fed.Servers { + if srv.DisplayName == "" || srv.URL == "" { + log.Warn().Str("federation", fed.Federation). + Str("displayName", srv.DisplayName). + Str("url", srv.URL). + Msg("Skipping server with missing displayName or url") + continue + } + + log.Debug().Str("federation", fed.Federation).Str("server", srv.DisplayName).Str("url", srv.URL).Msg("Discovering server") + + // Discover inviteAcceptDialog from OCM endpoint + disco, err := ocmClient.Discover(ctx, srv.URL) + if err != nil { + log.Warn().Err(err). + Str("federation", fed.Federation). + Str("server", srv.DisplayName). + Str("url", srv.URL). + Msg("Failed to discover server, skipping") + discoveryErrors++ + continue + } + + inviteDialog := disco.InviteAcceptDialog + + // If it's a relative path (starts with /), make it absolute + if inviteDialog != "" && inviteDialog[0] == '/' { + baseURL, parseErr := url.Parse(srv.URL) + if parseErr == nil { + inviteDialog = baseURL.Scheme + "://" + baseURL.Host + inviteDialog + log.Debug().Str("original", disco.InviteAcceptDialog).Str("converted", inviteDialog).Msg("Converted relative path to absolute") + } else { + log.Warn().Err(parseErr). + Str("url", srv.URL). + Str("inviteDialog", disco.InviteAcceptDialog). + Msg("Failed to parse server URL for relative path conversion") + continue + } + } + + validServers = append(validServers, FederationServer{ + DisplayName: srv.DisplayName, + URL: srv.URL, + InviteAcceptDialog: inviteDialog, + }) + validServersCount++ + + log.Debug(). + Str("federation", fed.Federation). + Str("server", srv.DisplayName). + Str("inviteAcceptDialog", inviteDialog). + Msg("Successfully discovered server") + } + + if len(validServers) > 0 { + h.federations = append(h.federations, Federation{ + Federation: fed.Federation, + Servers: validServers, + }) + log.Debug().Str("federation", fed.Federation).Int("valid_servers", len(validServers)).Msg("Added federation with valid servers") + } else { + log.Warn().Str("federation", fed.Federation). + Msg("Federation has no valid servers, skipping entirely") + } + } + + log.Info(). + Int("federations", len(h.federations)). + Int("valid_servers", validServersCount). + Int("discovery_errors", discoveryErrors). + Msg("WAYF handler initialization completed") + + return nil +} + +func (h *wayfHandler) GetFederations(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if err := json.NewEncoder(w).Encode(h.federations); err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error encoding response", err) + return + } +} + +func (h *wayfHandler) DiscoverProvider(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := appctx.GetLogger(ctx) + + var req DiscoverRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "Invalid request body", err) + return + } + + if req.Domain == "" { + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "Domain is required", nil) + return + } + + domain := req.Domain + if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") { + domain = "https://" + domain + } + + parsedURL, err := url.Parse(domain) + if err != nil || parsedURL.Host == "" { + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "Invalid domain format", err) + return + } + + // Create OCM client with timeout + ocmClient := ocmd.NewClient(10*time.Second, false) + + log.Debug().Str("domain", domain).Msg("Attempting OCM discovery") + disco, err := ocmClient.Discover(ctx, domain) + if err != nil { + log.Warn().Err(err).Str("domain", domain).Msg("Discovery failed") + reqres.WriteError(w, r, reqres.APIErrorNotFound, + fmt.Sprintf("Provider at '%s' does not support OCM discovery", req.Domain), err) + return + } + + inviteDialog := disco.InviteAcceptDialog + if inviteDialog != "" && inviteDialog[0] == '/' { + baseURL, _ := url.Parse(domain) + inviteDialog = baseURL.Scheme + "://" + baseURL.Host + inviteDialog + } + + response := DiscoverResponse{ + InviteAcceptDialog: inviteDialog, + } + + log.Info(). + Str("domain", req.Domain). + Str("inviteAcceptDialog", inviteDialog). + Msg("Discovery successful") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if err := json.NewEncoder(w).Encode(response); err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "Error encoding response", err) + return + } +} diff --git a/internal/http/services/wellknown/ocm.go b/internal/http/services/wellknown/ocm.go index 4644cefe04..42f8c84aaf 100644 --- a/internal/http/services/wellknown/ocm.go +++ b/internal/http/services/wellknown/ocm.go @@ -27,25 +27,27 @@ import ( "github.com/cs3org/reva/v2/pkg/appctx" ) -const OCMAPIVersion = "1.1.0" +const OCMAPIVersion = "1.2.0" type OcmProviderConfig struct { - OCMPrefix string `docs:"ocm;The prefix URL where the OCM API is served." mapstructure:"ocm_prefix"` - Endpoint string `docs:"This host's full URL. If it's not configured, it is assumed OCM is not available." mapstructure:"endpoint"` - Provider string `docs:"reva;A friendly name that defines this service." mapstructure:"provider"` - WebdavRoot string `docs:"/remote.php/dav/ocm;The root URL of the WebDAV endpoint to serve OCM shares." mapstructure:"webdav_root"` - WebappRoot string `docs:"/external/sciencemesh;The root URL to serve Web apps via OCM." mapstructure:"webapp_root"` - EnableWebapp bool `docs:"false;Whether web apps are enabled in OCM shares." mapstructure:"enable_webapp"` - EnableDatatx bool `docs:"false;Whether data transfers are enabled in OCM shares." mapstructure:"enable_datatx"` + OCMPrefix string `docs:"ocm;The prefix URL where the OCM API is served." mapstructure:"ocm_prefix"` + Endpoint string `docs:"This host's full URL. If it's not configured, it is assumed OCM is not available." mapstructure:"endpoint"` + Provider string `docs:"reva;A friendly name that defines this service." mapstructure:"provider"` + WebdavRoot string `docs:"/remote.php/dav/ocm;The root URL of the WebDAV endpoint to serve OCM shares." mapstructure:"webdav_root"` + WebappRoot string `docs:"/external/sciencemesh;The root URL to serve Web apps via OCM." mapstructure:"webapp_root"` + InviteAcceptDialog string `docs:"/open-cloud-mesh/accept-invite;The frontend URL where to land when receiving an invitation" mapstructure:"invite_accept_dialog"` + EnableWebapp bool `docs:"false;Whether web apps are enabled in OCM shares." mapstructure:"enable_webapp"` + EnableDatatx bool `docs:"false;Whether data transfers are enabled in OCM shares." mapstructure:"enable_datatx"` } type OcmDiscoveryData struct { - Enabled bool `json:"enabled" xml:"enabled"` - APIVersion string `json:"apiVersion" xml:"apiVersion"` - Endpoint string `json:"endPoint" xml:"endPoint"` - Provider string `json:"provider" xml:"provider"` - ResourceTypes []resourceTypes `json:"resourceTypes" xml:"resourceTypes"` - Capabilities []string `json:"capabilities" xml:"capabilities"` + Enabled bool `json:"enabled" xml:"enabled"` + APIVersion string `json:"apiVersion" xml:"apiVersion"` + Endpoint string `json:"endPoint" xml:"endPoint"` + Provider string `json:"provider" xml:"provider"` + ResourceTypes []resourceTypes `json:"resourceTypes" xml:"resourceTypes"` + Capabilities []string `json:"capabilities" xml:"capabilities"` + InviteAcceptDialog string `json:"inviteAcceptDialog" xml:"inviteAcceptDialog"` } type resourceTypes struct { @@ -77,6 +79,9 @@ func (c *OcmProviderConfig) ApplyDefaults() { if c.WebappRoot[len(c.WebappRoot)-1:] != "/" { c.WebappRoot += "/" } + if c.InviteAcceptDialog == "" { + c.InviteAcceptDialog = "/open-cloud-mesh/accept-invite" + } } func (h *wkocmHandler) init(c *OcmProviderConfig) { @@ -123,12 +128,13 @@ func (h *wkocmHandler) init(c *OcmProviderConfig) { ShareTypes: []string{"user"}, // so far we only support `user` Protocols: rtProtos, // expose the protocols as per configuration }} - // for now we hardcode the capabilities, as this is currently only advisory - d.Capabilities = []string{"/invite-accepted"} + // for now, we hardcoded the capabilities, as this is currently only advisory + d.Capabilities = []string{"invites", "webdav-uri", "protocol-object"} + d.InviteAcceptDialog, _ = url.JoinPath(c.Endpoint, c.InviteAcceptDialog) h.data = d } -// This handler implements the OCM discovery endpoint specified in +// Ocm This handler implements the OCM discovery endpoint specified in // https://cs3org.github.io/OCM-API/docs.html?repo=OCM-API&user=cs3org#/paths/~1ocm-provider/get func (h *wkocmHandler) Ocm(w http.ResponseWriter, r *http.Request) { log := appctx.GetLogger(r.Context()) From fb3c219ee1da978909e482c33e580d3b64323b08 Mon Sep 17 00:00:00 2001 From: Mahdi Baghbani Date: Mon, 27 Oct 2025 06:02:25 +0000 Subject: [PATCH 10/15] fix: unhandled error Signed-off-by: Mahdi Baghbani --- internal/http/services/ocmd/client.go | 7 ++++++- internal/http/services/sciencemesh/wayf.go | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/http/services/ocmd/client.go b/internal/http/services/ocmd/client.go index f0ed606028..1b2733d3ca 100644 --- a/internal/http/services/ocmd/client.go +++ b/internal/http/services/ocmd/client.go @@ -97,7 +97,12 @@ func (c *OCMClient) discover(ctx context.Context, url string) ([]byte, error) { if err != nil { return nil, errors.Wrap(err, "error doing OCM discovery request") } - defer resp.Body.Close() + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + log.Warn().Err(err).Msg("error closing response body") + } + }(resp.Body) if resp.StatusCode != http.StatusOK { log.Warn().Str("sender", url).Int("status", resp.StatusCode).Msg("discovery returned") return nil, errtypes.BadRequest("Remote does not offer a valid OCM discovery endpoint") diff --git a/internal/http/services/sciencemesh/wayf.go b/internal/http/services/sciencemesh/wayf.go index 196b5c979f..f9dbecc24c 100644 --- a/internal/http/services/sciencemesh/wayf.go +++ b/internal/http/services/sciencemesh/wayf.go @@ -106,7 +106,7 @@ func (h *wayfHandler) init(c *config) error { for _, fed := range fileData { log.Debug().Str("federation", fed.Federation).Int("servers_count", len(fed.Servers)).Msg("Processing federation") - validServers := []FederationServer{} + var validServers []FederationServer for _, srv := range fed.Servers { if srv.DisplayName == "" || srv.URL == "" { From 36e0ca470f5dc53bcb520122745cda58feb0d1b5 Mon Sep 17 00:00:00 2001 From: Mahdi Baghbani Date: Sat, 1 Nov 2025 09:28:58 +0000 Subject: [PATCH 11/15] add: @butonic recommendations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörn Friedrich Dreyer Signed-off-by: Mahdi Baghbani --- internal/http/services/ocmd/client.go | 10 +++++----- internal/http/services/sciencemesh/wayf.go | 2 +- internal/http/services/wellknown/ocm.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/http/services/ocmd/client.go b/internal/http/services/ocmd/client.go index 1b2733d3ca..f16d2da403 100644 --- a/internal/http/services/ocmd/client.go +++ b/internal/http/services/ocmd/client.go @@ -68,7 +68,7 @@ func (c *OCMClient) Discover(ctx context.Context, endpoint string) (*wellknown.O if err != nil || len(body) == 0 { log.Warn().Err(err).Str("sender", remoteurl).Str("response", string(body)). Msg("invalid or empty response") - return nil, errtypes.BadRequest("Invalid response on OCM discovery") + return nil, errtypes.InternalError("Invalid response on OCM discovery") } } @@ -77,7 +77,7 @@ func (c *OCMClient) Discover(ctx context.Context, endpoint string) (*wellknown.O if err != nil { log.Warn().Err(err).Str("sender", remoteurl).Str("response", string(body)). Msg("malformed response") - return nil, errtypes.BadRequest("Invalid payload on OCM discovery") + return nil, errtypes.InternalError("Invalid payload on OCM discovery") } log.Debug().Str("sender", remoteurl).Any("response", disco).Msg("discovery response") @@ -97,15 +97,15 @@ func (c *OCMClient) discover(ctx context.Context, url string) ([]byte, error) { if err != nil { return nil, errors.Wrap(err, "error doing OCM discovery request") } - defer func(Body io.ReadCloser) { - err := Body.Close() + defer func(body io.ReadCloser) { + err := body.Close() if err != nil { log.Warn().Err(err).Msg("error closing response body") } }(resp.Body) if resp.StatusCode != http.StatusOK { log.Warn().Str("sender", url).Int("status", resp.StatusCode).Msg("discovery returned") - return nil, errtypes.BadRequest("Remote does not offer a valid OCM discovery endpoint") + return nil, errtypes.NewErrtypeFromHTTPStatusCode(resp.StatusCode, "Remote does not offer a valid OCM discovery endpoint") } body, err := io.ReadAll(resp.Body) diff --git a/internal/http/services/sciencemesh/wayf.go b/internal/http/services/sciencemesh/wayf.go index f9dbecc24c..2173e62ae6 100644 --- a/internal/http/services/sciencemesh/wayf.go +++ b/internal/http/services/sciencemesh/wayf.go @@ -110,7 +110,7 @@ func (h *wayfHandler) init(c *config) error { for _, srv := range fed.Servers { if srv.DisplayName == "" || srv.URL == "" { - log.Warn().Str("federation", fed.Federation). + log.Debug().Str("federation", fed.Federation). Str("displayName", srv.DisplayName). Str("url", srv.URL). Msg("Skipping server with missing displayName or url") diff --git a/internal/http/services/wellknown/ocm.go b/internal/http/services/wellknown/ocm.go index 42f8c84aaf..5bb696c5dc 100644 --- a/internal/http/services/wellknown/ocm.go +++ b/internal/http/services/wellknown/ocm.go @@ -134,7 +134,7 @@ func (h *wkocmHandler) init(c *OcmProviderConfig) { h.data = d } -// Ocm This handler implements the OCM discovery endpoint specified in +// Ocm handles the OCM discovery endpoint specified in // https://cs3org.github.io/OCM-API/docs.html?repo=OCM-API&user=cs3org#/paths/~1ocm-provider/get func (h *wkocmHandler) Ocm(w http.ResponseWriter, r *http.Request) { log := appctx.GetLogger(r.Context()) From 5551ac12e3d1fb7f8cc9936d37a6ec401a90fc4c Mon Sep 17 00:00:00 2001 From: Mahdi Baghbani Date: Sat, 1 Nov 2025 09:31:42 +0000 Subject: [PATCH 12/15] fix: OCM client configurability Instead of creating a client each time a function is called, create client once Signed-off-by: Mahdi Baghbani --- .../http/services/sciencemesh/sciencemesh.go | 19 ++++++++++++------- internal/http/services/sciencemesh/wayf.go | 19 ++++++++++--------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/internal/http/services/sciencemesh/sciencemesh.go b/internal/http/services/sciencemesh/sciencemesh.go index a6ae42ce52..a047a2fec7 100644 --- a/internal/http/services/sciencemesh/sciencemesh.go +++ b/internal/http/services/sciencemesh/sciencemesh.go @@ -60,13 +60,15 @@ func (s *svc) Close() error { } type config struct { - Prefix string `mapstructure:"prefix"` - GatewaySvc string `mapstructure:"gatewaysvc" validate:"required"` - ProviderDomain string `mapstructure:"provider_domain" validate:"required"` - MeshDirectoryURL string `mapstructure:"mesh_directory_url"` - OCMMountPoint string `mapstructure:"ocm_mount_point"` - FederationsFile string `mapstructure:"federations_file"` - Events EventOptions `mapstructure:"events"` + Prefix string `mapstructure:"prefix"` + GatewaySvc string `mapstructure:"gatewaysvc" validate:"required"` + ProviderDomain string `mapstructure:"provider_domain" validate:"required"` + MeshDirectoryURL string `mapstructure:"mesh_directory_url"` + OCMMountPoint string `mapstructure:"ocm_mount_point"` + FederationsFile string `mapstructure:"federations_file"` + OCMClientTimeout int `mapstructure:"ocm_client_timeout"` + OCMClientInsecure bool `mapstructure:"ocm_client_insecure"` + Events EventOptions `mapstructure:"events"` } // EventOptions are the configurable options for events @@ -90,6 +92,9 @@ func (c *config) ApplyDefaults() { if c.FederationsFile == "" { c.FederationsFile = "/etc/revad/federations.json" } + if c.OCMClientTimeout == 0 { + c.OCMClientTimeout = 10 + } c.GatewaySvc = sharedconf.GetGatewaySVC(c.GatewaySvc) } diff --git a/internal/http/services/sciencemesh/wayf.go b/internal/http/services/sciencemesh/wayf.go index 2173e62ae6..e69d536ab6 100644 --- a/internal/http/services/sciencemesh/wayf.go +++ b/internal/http/services/sciencemesh/wayf.go @@ -35,6 +35,7 @@ import ( type wayfHandler struct { federations []Federation + ocmClient *ocmd.OCMClient } type Federation struct { @@ -71,6 +72,13 @@ type DiscoverResponse struct { func (h *wayfHandler) init(c *config) error { log := appctx.GetLogger(context.Background()) + // Create OCM client for discovery from config + h.ocmClient = ocmd.NewClient(time.Duration(c.OCMClientTimeout)*time.Second, c.OCMClientInsecure) + log.Debug(). + Int("timeout_seconds", c.OCMClientTimeout). + Bool("insecure", c.OCMClientInsecure). + Msg("Created OCM client for discovery") + log.Debug().Str("file", c.FederationsFile).Msg("Initializing WAYF handler with federations file") data, err := os.ReadFile(c.FederationsFile) @@ -92,10 +100,6 @@ func (h *wayfHandler) init(c *config) error { log.Debug().Int("federations_count", len(fileData)).Msg("Loaded federations from file") - // Create OCM client for discovery - ocmClient := ocmd.NewClient(10*time.Second, false) - log.Debug().Msg("Created OCM client for discovery") - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() @@ -120,7 +124,7 @@ func (h *wayfHandler) init(c *config) error { log.Debug().Str("federation", fed.Federation).Str("server", srv.DisplayName).Str("url", srv.URL).Msg("Discovering server") // Discover inviteAcceptDialog from OCM endpoint - disco, err := ocmClient.Discover(ctx, srv.URL) + disco, err := h.ocmClient.Discover(ctx, srv.URL) if err != nil { log.Warn().Err(err). Str("federation", fed.Federation). @@ -219,11 +223,8 @@ func (h *wayfHandler) DiscoverProvider(w http.ResponseWriter, r *http.Request) { return } - // Create OCM client with timeout - ocmClient := ocmd.NewClient(10*time.Second, false) - log.Debug().Str("domain", domain).Msg("Attempting OCM discovery") - disco, err := ocmClient.Discover(ctx, domain) + disco, err := h.ocmClient.Discover(ctx, domain) if err != nil { log.Warn().Err(err).Str("domain", domain).Msg("Discovery failed") reqres.WriteError(w, r, reqres.APIErrorNotFound, From 6710922e95f888561721ead154cd23a336883688 Mon Sep 17 00:00:00 2001 From: Mahdi Baghbani Date: Sat, 1 Nov 2025 09:32:43 +0000 Subject: [PATCH 13/15] add: capability Co-authored-by: Giuseppe Lo Presti Signed-off-by: Mahdi Baghbani --- internal/http/services/wellknown/ocm.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/http/services/wellknown/ocm.go b/internal/http/services/wellknown/ocm.go index 5bb696c5dc..5c74afb2f4 100644 --- a/internal/http/services/wellknown/ocm.go +++ b/internal/http/services/wellknown/ocm.go @@ -129,7 +129,7 @@ func (h *wkocmHandler) init(c *OcmProviderConfig) { Protocols: rtProtos, // expose the protocols as per configuration }} // for now, we hardcoded the capabilities, as this is currently only advisory - d.Capabilities = []string{"invites", "webdav-uri", "protocol-object"} + d.Capabilities = []string{"invites", "webdav-uri", "protocol-object", "invite-wayf"} d.InviteAcceptDialog, _ = url.JoinPath(c.Endpoint, c.InviteAcceptDialog) h.data = d } From 62de2196e86d457e8c2c3efd6b62f7d0f378af67 Mon Sep 17 00:00:00 2001 From: Mahdi Baghbani Date: Sat, 1 Nov 2025 19:07:31 +0000 Subject: [PATCH 14/15] add: ocm spec appendix C compliance @glpatcern recommendations moved types to spec.go used directory urls instead of a file Signed-off-by: Mahdi Baghbani --- internal/http/services/ocmd/client.go | 26 +++++ internal/http/services/ocmd/spec.go | 33 ++++++ .../http/services/sciencemesh/sciencemesh.go | 21 ++-- internal/http/services/sciencemesh/wayf.go | 104 +++++++----------- 4 files changed, 107 insertions(+), 77 deletions(-) create mode 100644 internal/http/services/ocmd/spec.go diff --git a/internal/http/services/ocmd/client.go b/internal/http/services/ocmd/client.go index f16d2da403..666a021ff4 100644 --- a/internal/http/services/ocmd/client.go +++ b/internal/http/services/ocmd/client.go @@ -114,3 +114,29 @@ func (c *OCMClient) discover(ctx context.Context, url string) ([]byte, error) { } return body, nil } + +// GetDirectoryService fetches a directory service listing from the given URL per OCM spec Appendix C. +func (c *OCMClient) GetDirectoryService(ctx context.Context, directoryURL string) (*DirectoryService, error) { + log := appctx.GetLogger(ctx) + + // TODO(@MahdiBaghbani): the discover() should be changed into a generic function that can be used to fetch any OCM endpoint. I'll do it in the security PR to minimize conflicts. + body, err := c.discover(ctx, directoryURL) + if err != nil { + return nil, errors.Wrap(err, "error fetching directory service") + } + + var dirService DirectoryService + if err := json.Unmarshal(body, &dirService); err != nil { + log.Warn().Err(err).Str("url", directoryURL).Str("response", string(body)).Msg("malformed directory service response") + return nil, errors.Wrap(err, "invalid directory service payload") + } + + // Validate required fields + if dirService.Federation == "" { + return nil, errtypes.InternalError("directory service missing required 'federation' field") + } + // Servers can be empty array, that's valid + + log.Debug().Str("url", directoryURL).Str("federation", dirService.Federation).Int("servers", len(dirService.Servers)).Msg("fetched directory service") + return &dirService, nil +} diff --git a/internal/http/services/ocmd/spec.go b/internal/http/services/ocmd/spec.go new file mode 100644 index 0000000000..913807120e --- /dev/null +++ b/internal/http/services/ocmd/spec.go @@ -0,0 +1,33 @@ +// Copyright 2025 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package ocmd + +// DirectoryService represents a directory service listing per OCM spec Appendix C. +type DirectoryService struct { + Federation string `json:"federation"` + Servers []DirectoryServiceServer `json:"servers"` +} + +// DirectoryServiceServer represents a single OCM server in a directory service. +type DirectoryServiceServer struct { + DisplayName string `json:"displayName"` + URL string `json:"url"` + // Added after discovery, not in raw response + InviteAcceptDialog string `json:"inviteAcceptDialog,omitempty"` +} diff --git a/internal/http/services/sciencemesh/sciencemesh.go b/internal/http/services/sciencemesh/sciencemesh.go index a047a2fec7..e867455f90 100644 --- a/internal/http/services/sciencemesh/sciencemesh.go +++ b/internal/http/services/sciencemesh/sciencemesh.go @@ -60,15 +60,15 @@ func (s *svc) Close() error { } type config struct { - Prefix string `mapstructure:"prefix"` - GatewaySvc string `mapstructure:"gatewaysvc" validate:"required"` - ProviderDomain string `mapstructure:"provider_domain" validate:"required"` - MeshDirectoryURL string `mapstructure:"mesh_directory_url"` - OCMMountPoint string `mapstructure:"ocm_mount_point"` - FederationsFile string `mapstructure:"federations_file"` - OCMClientTimeout int `mapstructure:"ocm_client_timeout"` - OCMClientInsecure bool `mapstructure:"ocm_client_insecure"` - Events EventOptions `mapstructure:"events"` + Prefix string `mapstructure:"prefix"` + GatewaySvc string `mapstructure:"gatewaysvc" validate:"required"` + ProviderDomain string `mapstructure:"provider_domain" validate:"required"` + MeshDirectoryURL string `mapstructure:"mesh_directory_url"` + OCMMountPoint string `mapstructure:"ocm_mount_point"` + DirectoryServiceURLs string `mapstructure:"directory_service_urls"` + OCMClientTimeout int `mapstructure:"ocm_client_timeout"` + OCMClientInsecure bool `mapstructure:"ocm_client_insecure"` + Events EventOptions `mapstructure:"events"` } // EventOptions are the configurable options for events @@ -89,9 +89,6 @@ func (c *config) ApplyDefaults() { if c.OCMMountPoint == "" { c.OCMMountPoint = "/ocm" } - if c.FederationsFile == "" { - c.FederationsFile = "/etc/revad/federations.json" - } if c.OCMClientTimeout == 0 { c.OCMClientTimeout = 10 } diff --git a/internal/http/services/sciencemesh/wayf.go b/internal/http/services/sciencemesh/wayf.go index e69d536ab6..1dab4b25b6 100644 --- a/internal/http/services/sciencemesh/wayf.go +++ b/internal/http/services/sciencemesh/wayf.go @@ -24,7 +24,6 @@ import ( "fmt" "net/http" "net/url" - "os" "strings" "time" @@ -34,31 +33,8 @@ import ( ) type wayfHandler struct { - federations []Federation - ocmClient *ocmd.OCMClient -} - -type Federation struct { - Federation string `json:"federation"` - Servers []FederationServer `json:"servers"` -} - -// FederationServer represents a single provider with discovery info -type FederationServer struct { - DisplayName string `json:"displayName"` - URL string `json:"url"` - InviteAcceptDialog string `json:"inviteAcceptDialog,omitempty"` -} - -// federationFile is the on-disk structure without inviteAcceptDialog -type federationFile struct { - Federation string `json:"federation"` - Servers []federationServerFile `json:"servers"` -} - -type federationServerFile struct { - DisplayName string `json:"displayName"` - URL string `json:"url"` + directoryServices []ocmd.DirectoryService + ocmClient *ocmd.OCMClient } type DiscoverRequest struct { @@ -79,55 +55,52 @@ func (h *wayfHandler) init(c *config) error { Bool("insecure", c.OCMClientInsecure). Msg("Created OCM client for discovery") - log.Debug().Str("file", c.FederationsFile).Msg("Initializing WAYF handler with federations file") - - data, err := os.ReadFile(c.FederationsFile) - if err != nil { - if os.IsNotExist(err) { - log.Warn().Str("file", c.FederationsFile).Msg("Federations file not found, starting with empty list") - h.federations = []Federation{} - return nil - } - log.Error().Err(err).Str("file", c.FederationsFile).Msg("Failed to read federations file") - return err - } - - var fileData []federationFile - if err := json.Unmarshal(data, &fileData); err != nil { - log.Error().Err(err).Str("file", c.FederationsFile).Msg("Failed to parse federations file") - return err + urls := strings.Fields(c.DirectoryServiceURLs) + if len(urls) == 0 { + log.Info().Msg("No directory service URLs configured, starting with empty list") + h.directoryServices = []ocmd.DirectoryService{} + return nil } - log.Debug().Int("federations_count", len(fileData)).Msg("Loaded federations from file") + log.Debug().Int("url_count", len(urls)).Strs("urls", urls).Msg("Initializing WAYF handler with directory service URLs") ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - // Discover each server and populate inviteAcceptDialog - h.federations = []Federation{} + h.directoryServices = []ocmd.DirectoryService{} discoveryErrors := 0 validServersCount := 0 + fetchErrors := 0 + + for _, directoryURL := range urls { + log.Debug().Str("url", directoryURL).Msg("Fetching directory service") + + directoryService, err := h.ocmClient.GetDirectoryService(ctx, directoryURL) + if err != nil { + log.Info().Err(err).Str("url", directoryURL).Msg("Failed to fetch directory service, skipping") + fetchErrors++ + continue + } - for _, fed := range fileData { - log.Debug().Str("federation", fed.Federation).Int("servers_count", len(fed.Servers)).Msg("Processing federation") - var validServers []FederationServer + log.Debug().Str("federation", directoryService.Federation).Int("servers_count", len(directoryService.Servers)).Msg("Processing directory service") - for _, srv := range fed.Servers { + var validServers []ocmd.DirectoryServiceServer + for _, srv := range directoryService.Servers { if srv.DisplayName == "" || srv.URL == "" { - log.Debug().Str("federation", fed.Federation). + log.Debug().Str("federation", directoryService.Federation). Str("displayName", srv.DisplayName). Str("url", srv.URL). Msg("Skipping server with missing displayName or url") continue } - log.Debug().Str("federation", fed.Federation).Str("server", srv.DisplayName).Str("url", srv.URL).Msg("Discovering server") + log.Debug().Str("federation", directoryService.Federation).Str("server", srv.DisplayName).Str("url", srv.URL).Msg("Discovering server") // Discover inviteAcceptDialog from OCM endpoint disco, err := h.ocmClient.Discover(ctx, srv.URL) if err != nil { - log.Warn().Err(err). - Str("federation", fed.Federation). + log.Debug().Err(err). + Str("federation", directoryService.Federation). Str("server", srv.DisplayName). Str("url", srv.URL). Msg("Failed to discover server, skipping") @@ -144,7 +117,7 @@ func (h *wayfHandler) init(c *config) error { inviteDialog = baseURL.Scheme + "://" + baseURL.Host + inviteDialog log.Debug().Str("original", disco.InviteAcceptDialog).Str("converted", inviteDialog).Msg("Converted relative path to absolute") } else { - log.Warn().Err(parseErr). + log.Debug().Err(parseErr). Str("url", srv.URL). Str("inviteDialog", disco.InviteAcceptDialog). Msg("Failed to parse server URL for relative path conversion") @@ -152,7 +125,7 @@ func (h *wayfHandler) init(c *config) error { } } - validServers = append(validServers, FederationServer{ + validServers = append(validServers, ocmd.DirectoryServiceServer{ DisplayName: srv.DisplayName, URL: srv.URL, InviteAcceptDialog: inviteDialog, @@ -160,27 +133,28 @@ func (h *wayfHandler) init(c *config) error { validServersCount++ log.Debug(). - Str("federation", fed.Federation). + Str("federation", directoryService.Federation). Str("server", srv.DisplayName). Str("inviteAcceptDialog", inviteDialog). Msg("Successfully discovered server") } if len(validServers) > 0 { - h.federations = append(h.federations, Federation{ - Federation: fed.Federation, + h.directoryServices = append(h.directoryServices, ocmd.DirectoryService{ + Federation: directoryService.Federation, Servers: validServers, }) - log.Debug().Str("federation", fed.Federation).Int("valid_servers", len(validServers)).Msg("Added federation with valid servers") + log.Debug().Str("federation", directoryService.Federation).Int("valid_servers", len(validServers)).Msg("Added directory service with valid servers") } else { - log.Warn().Str("federation", fed.Federation). - Msg("Federation has no valid servers, skipping entirely") + log.Info().Str("federation", directoryService.Federation). + Msg("Directory service has no valid servers, skipping entirely") } } log.Info(). - Int("federations", len(h.federations)). + Int("directory_services", len(h.directoryServices)). Int("valid_servers", validServersCount). + Int("fetch_errors", fetchErrors). Int("discovery_errors", discoveryErrors). Msg("WAYF handler initialization completed") @@ -191,7 +165,7 @@ func (h *wayfHandler) GetFederations(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(h.federations); err != nil { + if err := json.NewEncoder(w).Encode(h.directoryServices); err != nil { reqres.WriteError(w, r, reqres.APIErrorServerError, "error encoding response", err) return } @@ -226,7 +200,7 @@ func (h *wayfHandler) DiscoverProvider(w http.ResponseWriter, r *http.Request) { log.Debug().Str("domain", domain).Msg("Attempting OCM discovery") disco, err := h.ocmClient.Discover(ctx, domain) if err != nil { - log.Warn().Err(err).Str("domain", domain).Msg("Discovery failed") + log.Info().Err(err).Str("domain", domain).Msg("Discovery failed") reqres.WriteError(w, r, reqres.APIErrorNotFound, fmt.Sprintf("Provider at '%s' does not support OCM discovery", req.Domain), err) return From 943e0004de139d63811b9992b2733ee77cce44a3 Mon Sep 17 00:00:00 2001 From: Mahdi Baghbani Date: Wed, 5 Nov 2025 16:33:34 +0000 Subject: [PATCH 15/15] add: use standard libraries for url manipulation as per @glpatcern recommendations Also return 404 if the remote EFSS doesn't provide an inviteAcceptDialog Signed-off-by: Mahdi Baghbani --- internal/http/services/sciencemesh/wayf.go | 52 +++++++++++++++++----- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/internal/http/services/sciencemesh/wayf.go b/internal/http/services/sciencemesh/wayf.go index 1dab4b25b6..10cc72fe3f 100644 --- a/internal/http/services/sciencemesh/wayf.go +++ b/internal/http/services/sciencemesh/wayf.go @@ -45,6 +45,22 @@ type DiscoverResponse struct { InviteAcceptDialog string `json:"inviteAcceptDialog"` } +// makeAbsoluteURL takes a base URL and a path/URL and returns an absolute URL. +// If dialogURL is already absolute (has scheme and host), it returns it as-is. +// Otherwise, it joins the dialogURL with the baseURL to create an absolute URL. +func makeAbsoluteURL(baseURL, dialogURL string) (string, error) { + if dialogURL == "" { + return "", nil + } + + parsed, err := url.Parse(dialogURL) + if err == nil && parsed.Scheme != "" && parsed.Host != "" { + return dialogURL, nil + } + + return url.JoinPath(baseURL, dialogURL) +} + func (h *wayfHandler) init(c *config) error { log := appctx.GetLogger(context.Background()) @@ -110,19 +126,21 @@ func (h *wayfHandler) init(c *config) error { inviteDialog := disco.InviteAcceptDialog - // If it's a relative path (starts with /), make it absolute - if inviteDialog != "" && inviteDialog[0] == '/' { - baseURL, parseErr := url.Parse(srv.URL) - if parseErr == nil { - inviteDialog = baseURL.Scheme + "://" + baseURL.Host + inviteDialog - log.Debug().Str("original", disco.InviteAcceptDialog).Str("converted", inviteDialog).Msg("Converted relative path to absolute") - } else { - log.Debug().Err(parseErr). + if inviteDialog != "" { + absoluteURL, err := makeAbsoluteURL(srv.URL, inviteDialog) + if err != nil { + log.Debug().Err(err). + Str("federation", directoryService.Federation). + Str("server", srv.DisplayName). Str("url", srv.URL). Str("inviteDialog", disco.InviteAcceptDialog). - Msg("Failed to parse server URL for relative path conversion") + Msg("Failed to construct absolute URL, skipping server") continue } + if absoluteURL != inviteDialog { + log.Debug().Str("original", inviteDialog).Str("absolute", absoluteURL).Msg("Converted to absolute URL") + } + inviteDialog = absoluteURL } validServers = append(validServers, ocmd.DirectoryServiceServer{ @@ -207,9 +225,19 @@ func (h *wayfHandler) DiscoverProvider(w http.ResponseWriter, r *http.Request) { } inviteDialog := disco.InviteAcceptDialog - if inviteDialog != "" && inviteDialog[0] == '/' { - baseURL, _ := url.Parse(domain) - inviteDialog = baseURL.Scheme + "://" + baseURL.Host + inviteDialog + + if inviteDialog == "" { + log.Info().Str("domain", domain).Msg("Provider does not provide invite accept dialog") + reqres.WriteError(w, r, reqres.APIErrorNotFound, + fmt.Sprintf("Provider at '%s' does not provide an invite accept dialog", req.Domain), nil) + return + } + + inviteDialog, err = makeAbsoluteURL(domain, inviteDialog) + if err != nil { + log.Info().Err(err).Str("domain", domain).Str("inviteDialog", disco.InviteAcceptDialog).Msg("Failed to construct invite accept dialog URL") + reqres.WriteError(w, r, reqres.APIErrorServerError, "Failed to construct invite accept dialog URL", err) + return } response := DiscoverResponse{