diff --git a/.woodpecker.star b/.woodpecker.star index 266b8dc86d..c4c02009ca 100644 --- a/.woodpecker.star +++ b/.woodpecker.star @@ -2482,6 +2482,7 @@ def opencloudServer(storage = "decomposed", depends_on = [], deploy_type = "", e "OC_TRANSLATION_PATH": "%s/tests/config/translations" % dirs["base"], "ACTIVITYLOG_WRITE_BUFFER_DURATION": "0", # Disable write buffer so that test expectations are met in time "OC_LDAP_LOOKUP_CACHE_TTL": "0", # disable ldap lookup cache so that test fixture change are applied right away + "OC_ENABLE_GUEST_LINKS": True, # search grpc port needed for index cli tests "SEARCH_GRPC_ADDR": "0.0.0.0:9220", # debug addresses required for running services health tests @@ -2521,7 +2522,6 @@ def opencloudServer(storage = "decomposed", depends_on = [], deploy_type = "", e "WEBFINGER_DEBUG_ADDR": "0.0.0.0:9279", "STORAGE_USERS_POSIX_SCAN_DEBOUNCE_DELAY": 0, "OC_MACHINE_AUTH_API_KEY": MACHINE_AUTH_API_KEY, - "GRAPH_ENABLE_GUEST_INVITES": True, } if storage == "posix": diff --git a/Makefile b/Makefile index d014931b02..dc672e093e 100644 --- a/Makefile +++ b/Makefile @@ -30,6 +30,7 @@ OC_MODULES = \ services/auth-app \ services/auth-basic \ services/auth-bearer \ + services/auth-guest \ services/auth-machine \ services/auth-service \ services/clientlog \ diff --git a/go.mod b/go.mod index 44cfe9ee4b..edf987e782 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/golang/protobuf v1.5.4 github.com/google/go-cmp v0.7.0 github.com/google/go-tika v0.3.1 + github.com/google/renameio/v2 v2.0.2 github.com/google/uuid v1.6.0 github.com/gookit/config/v2 v2.2.9 github.com/gorilla/mux v1.8.1 @@ -235,7 +236,6 @@ require ( github.com/google/go-querystring v1.2.0 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect - github.com/google/renameio/v2 v2.0.2 // indirect github.com/gookit/goutil v0.8.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/schema v1.4.1 // indirect diff --git a/opencloud/pkg/command/services.go b/opencloud/pkg/command/services.go index 3c0d3852d0..d16e78dba3 100644 --- a/opencloud/pkg/command/services.go +++ b/opencloud/pkg/command/services.go @@ -15,6 +15,7 @@ import ( authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/command" authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/command" authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/command" + authguest "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/command" authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/command" authservice "github.com/opencloud-eu/opencloud/services/auth-service/pkg/command" clientlog "github.com/opencloud-eu/opencloud/services/clientlog/pkg/command" @@ -138,6 +139,11 @@ var serviceCommands = []register.Command{ cfg.Groups.Commons = cfg.Commons }) }, + func(cfg *config.Config) *cobra.Command { + return ServiceCommand(cfg, cfg.AuthGuest.Service.Name, authguest.GetCommands(cfg.AuthGuest), func(c *config.Config) { + cfg.AuthGuest.Commons = cfg.Commons + }) + }, func(cfg *config.Config) *cobra.Command { return ServiceCommand(cfg, cfg.IDM.Service.Name, idm.GetCommands(cfg.IDM), func(c *config.Config) { cfg.IDM.Commons = cfg.Commons diff --git a/opencloud/pkg/init/init.go b/opencloud/pkg/init/init.go index 83ae39e453..4e50d396c5 100644 --- a/opencloud/pkg/init/init.go +++ b/opencloud/pkg/init/init.go @@ -69,6 +69,7 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword idmServicePassword, idpServicePassword, ocAdminServicePassword, revaServicePassword string tokenManagerJwtSecret, collaborationWOPISecret, machineAuthAPIKey, systemUserAPIKey string revaTransferSecret, thumbnailsTransferSecret, serviceAccountSecret, urlSigningSecret string + authGuestJWTSecret string adminPasswdwordGenerated bool ) @@ -103,6 +104,13 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword return fmt.Errorf("could not generate random secret for urlSigningSecret: %s", err) } } + authGuestJWTSecret = oldCfg.AuthGuest.TokenManager.JWTSecret + if authGuestJWTSecret == "" { + authGuestJWTSecret, err = generators.GenerateRandomPassword(passwordLength) + if err != nil { + return fmt.Errorf("could not generate random secret for authGuestJWTSecret: %s", err) + } + } } else { systemUserID = uuid.NewString() adminUserID = uuid.NewString() @@ -155,6 +163,10 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword if err != nil { return fmt.Errorf("could not generate random secret for urlSigningSecret: %s", err) } + authGuestJWTSecret, err = generators.GenerateRandomPassword(passwordLength) + if err != nil { + return fmt.Errorf("could not generate random secret for authGuestJWTSecret: %s", err) + } thumbnailsTransferSecret, err = generators.GenerateRandomPassword(passwordLength) if err != nil { return fmt.Errorf("could not generate random password for thumbnailsTransferSecret: %s", err) @@ -212,6 +224,10 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword }, }, }, + AuthGuest: AuthGuest{ + ServiceAccount: serviceAccount, + TokenManager: TokenManager{JWTSecret: authGuestJWTSecret}, + }, Users: UsersAndGroupsService{ Drivers: LdapBasedService{ Ldap: LdapSettings{ diff --git a/opencloud/pkg/init/structs.go b/opencloud/pkg/init/structs.go index 3e6d0ae4f9..eec147250b 100644 --- a/opencloud/pkg/init/structs.go +++ b/opencloud/pkg/init/structs.go @@ -32,6 +32,7 @@ type OpenCloudConfig struct { AuthBearer AuthbearerService `yaml:"auth_bearer"` Users UsersAndGroupsService `yaml:"users"` Groups UsersAndGroupsService `yaml:"groups"` + AuthGuest AuthGuest `yaml:"auth_guest"` Ocm OcmService `yaml:"ocm"` Thumbnails ThumbnailService `yaml:"thumbnails"` Search Search `yaml:"search"` @@ -53,6 +54,12 @@ type Activitylog struct { ServiceAccount ServiceAccount `yaml:"service_account"` } +// AuthGuest is the configuration for the auth-guest service +type AuthGuest struct { + ServiceAccount ServiceAccount `yaml:"service_account"` + TokenManager TokenManager `yaml:"token_manager"` +} + // App is the configuration for the collaboration service type App struct { Insecure bool `yaml:"insecure"` diff --git a/opencloud/pkg/runtime/service/service.go b/opencloud/pkg/runtime/service/service.go index c360a623f2..6312b1cb0f 100644 --- a/opencloud/pkg/runtime/service/service.go +++ b/opencloud/pkg/runtime/service/service.go @@ -26,6 +26,7 @@ import ( audit "github.com/opencloud-eu/opencloud/services/audit/pkg/command" authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/command" authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/command" + authguest "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/command" authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/command" authservice "github.com/opencloud-eu/opencloud/services/auth-service/pkg/command" clientlog "github.com/opencloud-eu/opencloud/services/clientlog/pkg/command" @@ -198,6 +199,13 @@ func NewService(ctx context.Context, options ...Option) (*Service, error) { cfg.Groups.Commons = cfg.Commons return groups.Execute(cfg.Groups) }) + if opts.Config.Commons != nil && opts.Config.Commons.EnableGuestLinks { + reg(3, opts.Config.AuthGuest.Service.Name, func(ctx context.Context, cfg *occfg.Config) error { + cfg.AuthGuest.Context = ctx + cfg.AuthGuest.Commons = cfg.Commons + return authguest.Execute(cfg.AuthGuest) + }) + } reg(3, opts.Config.IDM.Service.Name, func(ctx context.Context, cfg *occfg.Config) error { cfg.IDM.Context = ctx cfg.IDM.Commons = cfg.Commons diff --git a/pkg/config/config.go b/pkg/config/config.go index 36536c8064..77f832d45f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,6 +10,7 @@ import ( authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/config" authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/config" authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/config" + authguest "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/config" authservice "github.com/opencloud-eu/opencloud/services/auth-service/pkg/config" clientlog "github.com/opencloud-eu/opencloud/services/clientlog/pkg/config" @@ -99,6 +100,7 @@ type Config struct { Gateway *gateway.Config `yaml:"gateway"` Graph *graph.Config `yaml:"graph"` Groups *groups.Config `yaml:"groups"` + AuthGuest *authguest.Config `yaml:"auth_guest"` IDM *idm.Config `yaml:"idm"` IDP *idp.Config `yaml:"idp"` Invitations *invitations.Config `yaml:"invitations"` diff --git a/pkg/config/defaultconfig.go b/pkg/config/defaultconfig.go index 59f5de02b2..586b5cd296 100644 --- a/pkg/config/defaultconfig.go +++ b/pkg/config/defaultconfig.go @@ -10,6 +10,7 @@ import ( authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/config/defaults" authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/config/defaults" authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/config/defaults" + authguest "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config/defaults" authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/config/defaults" authservice "github.com/opencloud-eu/opencloud/services/auth-service/pkg/config/defaults" clientlog "github.com/opencloud-eu/opencloud/services/clientlog/pkg/config/defaults" @@ -74,6 +75,7 @@ func DefaultConfig() *Config { Gateway: gateway.DefaultConfig(), Graph: graph.DefaultConfig(), Groups: groups.DefaultConfig(), + AuthGuest: authguest.DefaultConfig(), IDM: idm.DefaultConfig(), IDP: idp.DefaultConfig(), Invitations: invitations.DefaultConfig(), diff --git a/pkg/config/parser/parse.go b/pkg/config/parser/parse.go index 52e971636e..f67b68e862 100644 --- a/pkg/config/parser/parse.go +++ b/pkg/config/parser/parse.go @@ -58,15 +58,15 @@ func EnsureDefaults(cfg *config.Config) { if cfg.Reva == nil { cfg.Reva = &shared.Reva{} } -} -// EnsureCommons copies applicable parts of the OpenCloud config into the commons part -func EnsureCommons(cfg *config.Config) { // ensure the commons part is initialized if cfg.Commons == nil { cfg.Commons = &shared.Commons{} } +} +// EnsureCommons copies applicable parts of the OpenCloud config into the commons part +func EnsureCommons(cfg *config.Config) { cfg.Commons.Log = structs.CopyOrZeroValue(cfg.Log) cfg.Commons.Cache = structs.CopyOrZeroValue(cfg.Cache) diff --git a/pkg/events/events.go b/pkg/events/events.go index 3af491210f..4dfb695b61 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -5,6 +5,7 @@ import ( "time" user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" ) @@ -20,3 +21,19 @@ func (ResourceMention) Unmarshal(v []byte) (interface{}, error) { err := json.Unmarshal(v, &e) return e, err } + +type GuestTokenCreated struct { + ShareID *collaboration.ShareId + Sharer *user.UserId + GranteeEmail string + ItemID *provider.ResourceId + ResourceName string + Token string + Timestamp time.Time +} + +func (GuestTokenCreated) Unmarshal(v []byte) (interface{}, error) { + e := GuestTokenCreated{} + err := json.Unmarshal(v, &e) + return e, err +} diff --git a/pkg/shared/shared_types.go b/pkg/shared/shared_types.go index e817b75bf8..cb342014e5 100644 --- a/pkg/shared/shared_types.go +++ b/pkg/shared/shared_types.go @@ -80,6 +80,7 @@ type Commons struct { SystemUserAPIKey string `mask:"password" yaml:"system_user_api_key" env:"SYSTEM_USER_API_KEY" desc:"API key for all system users." introductionVersion:"1.0.0"` AdminUserID string `yaml:"admin_user_id" env:"OC_ADMIN_USER_ID" desc:"ID of a user, that should receive admin privileges. Consider that the UUID can be encoded in some LDAP deployment configurations like in .ldif files. These need to be decoded beforehand." introductionVersion:"1.0.0"` MultiTenantEnabled bool `yaml:"multi_tenant_enabled" env:"OC_MULTI_TENANT_ENABLED" desc:"Set this to true to enable multi-tenant support." introductionVersion:"4.0.0"` + EnableGuestLinks bool `yaml:"enable_guest_links" env:"OC_ENABLE_GUEST_LINKS" desc:"Enables the guest links feature: creating shares to mail addresses and redeeming guest links. Disabled by default." introductionVersion:"%%NEXT%%"` // NOTE: you will not fing GRPCMaxReceivedMessageSize size being used in the code. The envvar is actually extracted in revas `pool` package: https://github.com/cs3org/reva/blob/edge/pkg/rgrpc/todo/pool/connection.go // It is mentioned here again so it is documented diff --git a/services/auth-guest/.mockery.yaml b/services/auth-guest/.mockery.yaml new file mode 100644 index 0000000000..e290a57cf6 --- /dev/null +++ b/services/auth-guest/.mockery.yaml @@ -0,0 +1,14 @@ +# maintain v2 separate mocks dir +dir: "{{.InterfaceDir}}/mocks" +structname: "{{.InterfaceName}}" +filename: "{{.InterfaceName | snakecase }}.go" +pkgname: mocks + +template: testify +packages: + github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest: + interfaces: + AuthGuest: {} + github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage: + interfaces: + Manager: {} diff --git a/services/auth-guest/Makefile b/services/auth-guest/Makefile new file mode 100644 index 0000000000..bb440dd6dc --- /dev/null +++ b/services/auth-guest/Makefile @@ -0,0 +1,15 @@ +SHELL := bash +NAME := auth-guest + +ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI +include ../../.bingo/Variables.mk +endif + +include ../../.make/default.mk +include ../../.make/go.mk +include ../../.make/release.mk +include ../../.make/docs.mk + +.PHONY: go-generate +go-generate: $(MOCKERY) + $(MOCKERY) diff --git a/services/auth-guest/README.md b/services/auth-guest/README.md new file mode 100644 index 0000000000..1e763f7dbf --- /dev/null +++ b/services/auth-guest/README.md @@ -0,0 +1,101 @@ +# auth-guest + +The `auth-guest` service gives guest users access to a share without a full +OpenCloud account. When a share is created for a user of type +`USER_TYPE_GUEST`, the service issues a one-time guest link token; redeeming +that token exchanges it for a signed session cookie that authenticates the +guest. + +It is disabled by default. Set `OC_ENABLE_GUEST_LINKS=true` to enable the guest +links feature and start the service. + +## Overview + +- **Consumes** the share lifecycle events `ShareCreated`, `ShareRemoved` and + `ShareExpired`. +- **Publishes** the `GuestTokenCreated` event carrying the guest link token, + so the link can be delivered to the guest. +- Exposes an unauthenticated endpoint that redeems the token and sets a + session cookie. +- Stores only hashes of the token and deletes the stored record when the share + is removed or expires. + +## Guest links flow + +The following sequence diagram describes the guest links flow: + +```mermaid +sequenceDiagram + autonumber + actor User as Guest user + participant Web as Web client + participant Redeem as Redeem endpoint + participant Proxy as OpenCloud proxy + participant Graph as Graph / sharedWithMe + participant DAV as WebDAV + participant Reva as Reva + + User->>Web: Open guest link with valid token + Web->>+Redeem: Redeem Token + Note right of Redeem: Validate Token + Redeem->>+Reva: Get Share + Reva->>-Redeem: Share + Note right of Redeem: Validate Share, Mark Token used + Redeem->>-Web: Set Cookie, return shareid + Note right of Web: HTTP only cookie with signed JWT (JWT lifetime 24h) + Web->>+Proxy: "/graph/me/drives/sharedWithMe" + Proxy->>+Reva: validate token extracted from JWT + Note right of Reva: Sign Reva Token for Guest User + Reva->>-Proxy: Authenticated + Proxy->>+Graph: "/graph/me/drives/sharedWithMe" + Note right of Proxy: Using Reva Token + Graph->>+Reva: Requests to ShareProvider + Reva->>-Graph: Shares + Graph->>-Proxy: driveItems (all shares for the Guest User) + Proxy->>-Web: driveItems + Note right of Web: Extracts driveItem for the specific share + Web->>+Proxy: PROPFIND (resource id extracted from driveItem) + Note right of Web: Using Cookie + Proxy->>+Reva: validate token extracted from JWT + Note right of Reva: Sign Reva Token for Guest User + Reva->>-Proxy: Authenticated + Proxy->>+DAV: PROPFIND + Note right of Proxy: Using Reva Token + DAV->>+Reva: Requests to StorageProvider + Reva->>-DAV: StorageProvider Responses + DAV->>-Proxy: PROPFIND Response + Proxy->>-Web: PROPFIND Response +``` + +## Token lifecycle + +1. **Issue** — on the consumed `ShareCreated` event, where the grantee is a + guest, the service generates a random secret and stores a record keyed by + the hash of the share id. It then publishes the `GuestTokenCreated` event + with the token. +2. **Redeem** — the guest posts the token to + `POST /graph/v1beta1/extensions/org.libregraph/guestLinks/redeem`. + The service validates the token and the share, marks the token as used + and returns a signed JWT session token in a cookie plus the share's + `permissionId` in the response body. Tokens are single-use. +3. **Cleanup** — on the consumed `ShareRemoved` or `ShareExpired` event, the + stored record is deleted. + +## Configuration + +The service is configured via `AUTH_GUEST_*` environment variables or a +`auth-guest.yaml` file. + +To run only the HTTP part, set `AUTH_GUEST_EVENTS_DISABLED=true`. To run only +the event consumer, set `AUTH_GUEST_HTTP_DISABLED=true`. + +Relevant options: + +- `AUTH_GUEST_JWT_SECRET` — secret used to sign session tokens. +- `AUTH_GUEST_JWT_COOKIE_NAME`, `AUTH_GUEST_JWT_TTL` — session cookie name and + lifetime. +- `AUTH_GUEST_TOKENS_STORAGE_ROOT` — where guest link token records are stored. +- `AUTH_GUEST_SERVICE_ACCOUNT_ID`, `AUTH_GUEST_SERVICE_ACCOUNT_SECRET` — service + account used to query the gateway for share metadata. +- `AUTH_GUEST_NUM_CONSUMERS` — number of concurrent event consumers. +- `OC_REVA_GATEWAY` — CS3 gateway used to look up shares. diff --git a/services/auth-guest/pkg/command/health.go b/services/auth-guest/pkg/command/health.go new file mode 100644 index 0000000000..6825fe20a0 --- /dev/null +++ b/services/auth-guest/pkg/command/health.go @@ -0,0 +1,21 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package command + +import ( + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" + "github.com/spf13/cobra" +) + +// Health is the entrypoint for the health command. +func Health(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "health", + Short: "Check health status", + RunE: func(cmd *cobra.Command, args []string) error { + // not implemented + return nil + }, + } +} diff --git a/services/auth-guest/pkg/command/root.go b/services/auth-guest/pkg/command/root.go new file mode 100644 index 0000000000..114a5a407a --- /dev/null +++ b/services/auth-guest/pkg/command/root.go @@ -0,0 +1,37 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package command + +import ( + "os" + + "github.com/opencloud-eu/opencloud/pkg/clihelper" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" + "github.com/spf13/cobra" +) + +// GetCommands provides all commands for this service +func GetCommands(cfg *config.Config) []*cobra.Command { + return []*cobra.Command{ + // start this service + Server(cfg), + + // interaction with this service + + // infos about this service + Health(cfg), + Version(cfg), + } +} + +// Execute is the entry point for the auth-guest command. +func Execute(cfg *config.Config) error { + app := clihelper.DefaultApp(&cobra.Command{ + Use: "auth-guest", + Short: "starts auth-guest service", + }) + app.AddCommand(GetCommands(cfg)...) + app.SetArgs(os.Args[1:]) + return app.ExecuteContext(cfg.Context) +} diff --git a/services/auth-guest/pkg/command/server.go b/services/auth-guest/pkg/command/server.go new file mode 100644 index 0000000000..204ec6b290 --- /dev/null +++ b/services/auth-guest/pkg/command/server.go @@ -0,0 +1,172 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package command + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/opencloud-eu/opencloud/pkg/config/configlog" + "github.com/opencloud-eu/opencloud/pkg/generators" + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/pkg/registry" + "github.com/opencloud-eu/opencloud/pkg/runner" + "github.com/opencloud-eu/opencloud/pkg/tracing" + "github.com/opencloud-eu/opencloud/pkg/version" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config/parser" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/metrics" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/server/debug" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/server/http" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest" + svcEvents "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/events" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token" + "github.com/opencloud-eu/reva/v2/pkg/events" + "github.com/opencloud-eu/reva/v2/pkg/events/stream" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" +) + +var _registeredEvents = []events.Unmarshaller{ + events.ShareCreated{}, + events.ShareRemoved{}, + events.ShareExpired{}, +} + +// Server is the entrypoint for the server command. +func Server(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "server", + Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name), + PreRunE: func(cmd *cobra.Command, args []string) error { + return configlog.ReturnFatal(parser.ParseConfig(cfg)) + }, + RunE: func(cmd *cobra.Command, args []string) error { + logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel) + + tracerProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name) + if err != nil { + return err + } + + tm, err := pool.StringToTLSMode(cfg.GRPCClientTLS.Mode) + if err != nil { + return err + } + gatewaySelector, err := pool.GatewaySelector( + cfg.RevaGateway, + pool.WithTLSCACert(cfg.GRPCClientTLS.CACert), + pool.WithTLSMode(tm), + pool.WithRegistry(registry.GetRegistry()), + pool.WithTracerProvider(tracerProvider), + ) + if err != nil { + return fmt.Errorf("could not get reva client selector: %s", err) + } + + gr := runner.NewGroup() + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + + mtrcs := metrics.New() + mtrcs.BuildInfo.WithLabelValues(version.GetString()).Set(1) + + tokenSvc := token.NewTokenService() + store := storage.NewFileManager(cfg.Storage.RootDirectory) + jwtService := jwt.NewJwtService(cfg.TokenManager.JWTSecret, cfg.JWT.TTL) + + authGuest := authguest.NewAuthGuestService(tokenSvc, store, + authguest.GatewaySelector(gatewaySelector), + authguest.ServiceAccount(cfg.ServiceAccount), + authguest.JWT(jwtService), + ) + + if !cfg.HTTP.Disabled { + server, err := http.Server( + http.Logger(logger), + http.Context(ctx), + http.Config(cfg), + http.Service(authGuest), + ) + if err != nil { + logger.Info(). + Err(err). + Str("transport", "http"). + Msg("Failed to initialize server") + + return err + } + + gr.Add(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server)) + } else { + logger.Info().Msg("HTTP server disabled, not starting HTTP service") + } + + if !cfg.Events.Disabled { + connName := generators.GenerateConnectionName(cfg.Service.Name, generators.NTypeBus) + evStream, err := stream.NatsFromConfig(connName, false, stream.NatsConfig{ + Endpoint: cfg.Events.Endpoint, + Cluster: cfg.Events.Cluster, + EnableTLS: cfg.Events.EnableTLS, + TLSInsecure: cfg.Events.TLSInsecure, + TLSRootCACertificate: cfg.Events.TLSRootCACertificate, + AuthUsername: cfg.Events.AuthUsername, + AuthPassword: cfg.Events.AuthPassword, + }) + if err != nil { + logger.Error().Err(err).Msg("Failed to initialize event stream") + return err + } + + consumer, err := svcEvents.NewEventConsumer( + evStream, + svcEvents.Logger(logger), + svcEvents.Context(ctx), + svcEvents.RegisteredEvents(_registeredEvents), + svcEvents.NumConsumers(cfg.NumConsumers), + svcEvents.AuthGuestService(authGuest), + ) + if err != nil { + logger.Error().Err(err).Str("transport", "event").Msg("Failed to initialize server") + return err + } + + gr.Add(runner.New(cfg.Service.Name+".svc", func() error { + return consumer.Run() + }, func() { + consumer.Close() + })) + } else { + logger.Info().Msg("event listening disabled, not starting event service") + } + + { + debugServer, err := debug.Server( + debug.Logger(logger), + debug.Context(ctx), + debug.Config(cfg), + ) + if err != nil { + logger.Info().Err(err).Str("server", "debug").Msg("Failed to initialize server") + return err + } + + gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer)) + } + + grResults := gr.Run(ctx) + + // return the first non-nil error found in the results + for _, grResult := range grResults { + if grResult.RunnerError != nil { + return grResult.RunnerError + } + } + return nil + }, + } +} diff --git a/services/auth-guest/pkg/command/version.go b/services/auth-guest/pkg/command/version.go new file mode 100644 index 0000000000..228f6ae325 --- /dev/null +++ b/services/auth-guest/pkg/command/version.go @@ -0,0 +1,21 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package command + +import ( + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" + "github.com/spf13/cobra" +) + +// Version prints the service versions of all running instances. +func Version(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the version of this binary and the running service instances", + RunE: func(cmd *cobra.Command, args []string) error { + // not implemented + return nil + }, + } +} diff --git a/services/auth-guest/pkg/config/config.go b/services/auth-guest/pkg/config/config.go new file mode 100644 index 0000000000..1e24494681 --- /dev/null +++ b/services/auth-guest/pkg/config/config.go @@ -0,0 +1,90 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "context" + "time" + + "github.com/opencloud-eu/opencloud/pkg/shared" +) + +// Config combines all available configuration parts. +type Config struct { + Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service + + Service Service `yaml:"-"` + + LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;AUTH_GUEST_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"%%NEXT%%"` + + Debug Debug `yaml:"debug"` + + Events Events `yaml:"events"` + + RevaGateway string `yaml:"reva_gateway" env:"OC_REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata" introductionVersion:"%%NEXT%%"` + GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"` + + HTTP HTTP `yaml:"http"` + Storage Storage `yaml:"storage"` + TokenManager *TokenManager `yaml:"token_manager"` + JWT JWT `yaml:"jwt"` + + ServiceAccount ServiceAccount `yaml:"service_account"` + + NumConsumers int `yaml:"num_consumers" env:"AUTH_GUEST_NUM_CONSUMERS" desc:"The amount of concurrent event consumers to start. Event consumers are used for processing events. Multiple consumers increase parallelisation, but will also increase CPU and memory demands." introductionVersion:"%%NEXT%%"` + + Context context.Context `yaml:"-"` +} + +// Events combines the configuration options for the event bus. +type Events struct { + Disabled bool `yaml:"disabled" env:"AUTH_GUEST_EVENTS_DISABLED" desc:"Disables listening for events. Set this to true if the service should only handle HTTP requests." introductionVersion:"%%NEXT%%"` + Endpoint string `yaml:"endpoint" env:"OC_EVENTS_ENDPOINT" desc:"The address of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture." introductionVersion:"%%NEXT%%"` + Cluster string `yaml:"cluster" env:"OC_EVENTS_CLUSTER" desc:"The clusterID of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture. Mandatory when using NATS as event system." introductionVersion:"%%NEXT%%"` + TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"%%NEXT%%"` + TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided AUTH_GUEST_EVENTS_TLS_INSECURE will be seen as false." introductionVersion:"%%NEXT%%"` + EnableTLS bool `yaml:"enable_tls" env:"OC_EVENTS_ENABLE_TLS" desc:"Enable TLS for the connection to the events broker. The events broker is the OpenCloud service which receives and delivers events between the services." introductionVersion:"%%NEXT%%"` + AuthUsername string `yaml:"username" env:"OC_EVENTS_AUTH_USERNAME" desc:"The username to authenticate with the events broker. The events broker is the OpenCloud service which receives and delivers events between the services." introductionVersion:"%%NEXT%%"` + AuthPassword string `yaml:"password" env:"OC_EVENTS_AUTH_PASSWORD" desc:"The password to authenticate with the events broker. The events broker is the OpenCloud service which receives and delivers events between the services." introductionVersion:"%%NEXT%%"` +} + +// ServiceAccount is the configuration for the used service account +type ServiceAccount struct { + ServiceAccountID string `yaml:"service_account_id" env:"OC_SERVICE_ACCOUNT_ID;AUTH_GUEST_SERVICE_ACCOUNT_ID" desc:"The ID of the service account the service should use. See the 'auth-service' service description for more details." introductionVersion:"%%NEXT%%"` + ServiceAccountSecret string `yaml:"service_account_secret" env:"OC_SERVICE_ACCOUNT_SECRET;AUTH_GUEST_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"%%NEXT%%"` +} + +// CORS defines the available cors configuration. +type CORS struct { + AllowedOrigins []string `yaml:"allow_origins" env:"OC_CORS_ALLOW_ORIGINS;AUTH_GUEST_CORS_ALLOW_ORIGINS" desc:"A list of allowed CORS origins. See following chapter for more details: *Access-Control-Allow-Origin* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"` + AllowedMethods []string `yaml:"allow_methods" env:"OC_CORS_ALLOW_METHODS;AUTH_GUEST_CORS_ALLOW_METHODS" desc:"A list of allowed CORS methods. See following chapter for more details: *Access-Control-Request-Method* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"` + AllowedHeaders []string `yaml:"allow_headers" env:"OC_CORS_ALLOW_HEADERS;AUTH_GUEST_CORS_ALLOW_HEADERS" desc:"A list of allowed CORS headers. See following chapter for more details: *Access-Control-Request-Headers* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"` + AllowCredentials bool `yaml:"allow_credentials" env:"OC_CORS_ALLOW_CREDENTIALS;AUTH_GUEST_CORS_ALLOW_CREDENTIALS" desc:"Allow credentials for CORS.See following chapter for more details: *Access-Control-Allow-Credentials* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials." introductionVersion:"%%NEXT%%"` +} + +// HTTP defines the available http configuration. +type HTTP struct { + Disabled bool `yaml:"disabled" env:"AUTH_GUEST_HTTP_DISABLED" desc:"Disables the HTTP service. Set this to true if the service should only handle events." introductionVersion:"%%NEXT%%"` + Addr string `yaml:"addr" env:"AUTH_GUEST_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"%%NEXT%%"` + Namespace string `yaml:"-"` + Root string `yaml:"root" env:"AUTH_GUEST_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"%%NEXT%%"` + CORS CORS `yaml:"cors"` + TLS shared.HTTPServiceTLS `yaml:"tls"` +} + +// Storage defines the configuration for the token storage. +type Storage struct { + RootDirectory string `yaml:"root_directory" env:"AUTH_GUEST_TOKENS_STORAGE_ROOT" desc:"The directory where the guest share tokens are stored. If not defined, the root directory derives from $OC_BASE_DATA_PATH/auth-guest." introductionVersion:"%%NEXT%%"` +} + +// TokenManager is the config for using the reva token manager +type TokenManager struct { + JWTSecret string `yaml:"jwt_secret" env:"AUTH_GUEST_JWT_SECRET" desc:"The secret to mint and validate jwt tokens." introductionVersion:"%%NEXT%%"` +} + +// JWT defines the configuration for guest session tokens. +type JWT struct { + CookieName string `yaml:"cookie_name" env:"AUTH_GUEST_JWT_COOKIE_NAME" desc:"The name of the session cookie set when a guest token is redeemed." introductionVersion:"%%NEXT%%"` + TTL time.Duration `yaml:"ttl" env:"AUTH_GUEST_JWT_TTL" desc:"The lifetime of a redeemed guest session token." introductionVersion:"%%NEXT%%"` +} diff --git a/services/auth-guest/pkg/config/debug.go b/services/auth-guest/pkg/config/debug.go new file mode 100644 index 0000000000..79013cf436 --- /dev/null +++ b/services/auth-guest/pkg/config/debug.go @@ -0,0 +1,12 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package config + +// Debug defines the available debug configuration. +type Debug struct { + Addr string `yaml:"addr" env:"AUTH_GUEST_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"%%NEXT%%"` + Token string `yaml:"token" env:"AUTH_GUEST_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"%%NEXT%%"` + Pprof bool `yaml:"pprof" env:"AUTH_GUEST_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"%%NEXT%%"` + Zpages bool `yaml:"zpages" env:"AUTH_GUEST_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"%%NEXT%%"` +} diff --git a/services/auth-guest/pkg/config/defaults/defaultconfig.go b/services/auth-guest/pkg/config/defaults/defaultconfig.go new file mode 100644 index 0000000000..52e2632c6e --- /dev/null +++ b/services/auth-guest/pkg/config/defaults/defaultconfig.go @@ -0,0 +1,85 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package defaults + +import ( + "path" + "time" + + "github.com/opencloud-eu/opencloud/pkg/config/defaults" + "github.com/opencloud-eu/opencloud/pkg/shared" + "github.com/opencloud-eu/opencloud/pkg/structs" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" +) + +// FullDefaultConfig returns the full default config +func FullDefaultConfig() *config.Config { + cfg := DefaultConfig() + EnsureDefaults(cfg) + Sanitize(cfg) + return cfg +} + +// DefaultConfig return the default configuration +func DefaultConfig() *config.Config { + return &config.Config{ + Debug: config.Debug{ + Addr: "127.0.0.1:9267", + Token: "", + Pprof: false, + Zpages: false, + }, + Service: config.Service{ + Name: "auth-guest", + }, + NumConsumers: 1, + Events: config.Events{ + Endpoint: "127.0.0.1:9233", + Cluster: "opencloud-cluster", + EnableTLS: false, + }, + RevaGateway: shared.DefaultRevaConfig().Address, + HTTP: config.HTTP{ + Addr: "127.0.0.1:9266", + Root: "/graph", + Namespace: "eu.opencloud.web", + CORS: config.CORS{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, + AllowedHeaders: []string{"Authorization", "Origin", "Content-Type", "Accept", "X-Requested-With", "X-Request-Id", "Ocs-Apirequest"}, + AllowCredentials: true, + }, + }, + Storage: config.Storage{ + RootDirectory: path.Join(defaults.BaseDataPath(), "auth-guest"), + }, + JWT: config.JWT{ + CookieName: "__Host-oc_guest_session", + TTL: 24 * time.Hour, + }, + } +} + +// EnsureDefaults ensures the config contains default values +func EnsureDefaults(cfg *config.Config) { + if cfg.LogLevel == "" { + cfg.LogLevel = "error" + } + if cfg.GRPCClientTLS == nil && cfg.Commons != nil { + cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS) + } + + if cfg.TokenManager == nil { + cfg.TokenManager = &config.TokenManager{} + } + + if cfg.Commons != nil { + cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS + } +} + +// Sanitize sanitizes the config +func Sanitize(cfg *config.Config) { + // sanitize config +} diff --git a/services/auth-guest/pkg/config/parser/parse.go b/services/auth-guest/pkg/config/parser/parse.go new file mode 100644 index 0000000000..b2add76af3 --- /dev/null +++ b/services/auth-guest/pkg/config/parser/parse.go @@ -0,0 +1,45 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import ( + "errors" + + occfg "github.com/opencloud-eu/opencloud/pkg/config" + "github.com/opencloud-eu/opencloud/pkg/shared" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config/defaults" + + "github.com/opencloud-eu/opencloud/pkg/config/envdecode" +) + +// ParseConfig loads configuration from known paths. +func ParseConfig(cfg *config.Config) error { + err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg) + if err != nil { + return err + } + + defaults.EnsureDefaults(cfg) + + // load all env variables relevant to the config in the current context. + if err := envdecode.Decode(cfg); err != nil { + // no environment variable set for this config is an expected "error" + if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) { + return err + } + } + + defaults.Sanitize(cfg) + + return Validate(cfg) +} + +// Validate validates the config +func Validate(cfg *config.Config) error { + if cfg.TokenManager == nil || cfg.TokenManager.JWTSecret == "" { + return shared.MissingJWTTokenError(cfg.Service.Name) + } + return nil +} diff --git a/services/auth-guest/pkg/config/service.go b/services/auth-guest/pkg/config/service.go new file mode 100644 index 0000000000..1716901eff --- /dev/null +++ b/services/auth-guest/pkg/config/service.go @@ -0,0 +1,9 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package config + +// Service defines the available service configuration. +type Service struct { + Name string `yaml:"-"` +} diff --git a/services/auth-guest/pkg/metrics/metrics.go b/services/auth-guest/pkg/metrics/metrics.go new file mode 100644 index 0000000000..3f79e38f7c --- /dev/null +++ b/services/auth-guest/pkg/metrics/metrics.go @@ -0,0 +1,38 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package metrics + +import "github.com/prometheus/client_golang/prometheus" + +var ( + // Namespace defines the namespace for the defines metrics. + Namespace = "opencloud" + + // Subsystem defines the subsystem for the defines metrics. + Subsystem = "auth-guest" +) + +// Metrics defines the available metrics of this service. +type Metrics struct { + BuildInfo *prometheus.GaugeVec +} + +// New initializes the available metrics. +func New() *Metrics { + m := &Metrics{ + BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "build_info", + Help: "Build information", + }, []string{"version"}), + } + + _ = prometheus.Register( + m.BuildInfo, + ) + + // TODO: implement metrics + return m +} diff --git a/services/auth-guest/pkg/server/debug/option.go b/services/auth-guest/pkg/server/debug/option.go new file mode 100644 index 0000000000..bf9b9e16d0 --- /dev/null +++ b/services/auth-guest/pkg/server/debug/option.go @@ -0,0 +1,53 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package debug + +import ( + "context" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + Logger log.Logger + Context context.Context + Config *config.Config +} + +// newOptions initializes the available default options. +func newOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// Logger provides a function to set the logger option. +func Logger(val log.Logger) Option { + return func(o *Options) { + o.Logger = val + } +} + +// Context provides a function to set the context option. +func Context(val context.Context) Option { + return func(o *Options) { + o.Context = val + } +} + +// Config provides a function to set the config option. +func Config(val *config.Config) Option { + return func(o *Options) { + o.Config = val + } +} diff --git a/services/auth-guest/pkg/server/debug/server.go b/services/auth-guest/pkg/server/debug/server.go new file mode 100644 index 0000000000..5b6b9ef8ee --- /dev/null +++ b/services/auth-guest/pkg/server/debug/server.go @@ -0,0 +1,43 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package debug + +import ( + "net/http" + + "github.com/opencloud-eu/opencloud/pkg/checks" + "github.com/opencloud-eu/opencloud/pkg/handlers" + "github.com/opencloud-eu/opencloud/pkg/nats" + "github.com/opencloud-eu/opencloud/pkg/service/debug" + "github.com/opencloud-eu/opencloud/pkg/version" +) + +// Server initializes the debug service and server. +func Server(opts ...Option) (*http.Server, error) { + options := newOptions(opts...) + + healthHandlerConfiguration := handlers.NewCheckHandlerConfiguration(). + WithLogger(options.Logger). + WithCheck("http reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr)) + + secureOption := nats.Secure( + options.Config.Events.EnableTLS, + options.Config.Events.TLSInsecure, + options.Config.Events.TLSRootCACertificate, + ) + readyHandlerConfiguration := healthHandlerConfiguration. + WithCheck("nats reachability", checks.NewNatsCheck(options.Config.Events.Endpoint, secureOption)) + + return debug.NewService( + debug.Logger(options.Logger), + debug.Name(options.Config.Service.Name), + debug.Version(version.GetString()), + debug.Address(options.Config.Debug.Addr), + debug.Token(options.Config.Debug.Token), + debug.Pprof(options.Config.Debug.Pprof), + debug.Zpages(options.Config.Debug.Zpages), + debug.Health(handlers.NewCheckHandler(healthHandlerConfiguration)), + debug.Ready(handlers.NewCheckHandler(readyHandlerConfiguration)), + ), nil +} diff --git a/services/auth-guest/pkg/server/http/errors.go b/services/auth-guest/pkg/server/http/errors.go new file mode 100644 index 0000000000..869d0f6234 --- /dev/null +++ b/services/auth-guest/pkg/server/http/errors.go @@ -0,0 +1,60 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package http + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token" +) + +type errorResponse struct { + ErrorType string `json:"errorType"` + Message string `json:"message"` + PermissionID string `json:"permissionId"` +} + +func writeError(w http.ResponseWriter, status int, body errorResponse) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeRedeemError(w http.ResponseWriter, err error) { + var re *authguest.RedeemError + if !errors.As(err, &re) { + writeError(w, http.StatusInternalServerError, errorResponse{ErrorType: "internalError", Message: "An internal error occurred."}) + return + } + + status := http.StatusInternalServerError + errorType := "internalError" + switch { + case errors.Is(re.ErrorType, authguest.ErrExpired): + status, errorType = http.StatusUnauthorized, "tokenExpired" + case errors.Is(re.ErrorType, token.ErrInvalidToken): + status, errorType = http.StatusUnauthorized, "tokenInvalid" + case errors.Is(re.ErrorType, storage.ErrNotFound): + status, errorType = http.StatusNotFound, "tokenNotFound" + case errors.Is(re.ErrorType, storage.ErrInvalidHash): + status, errorType = http.StatusUnauthorized, "tokenInvalid" + case errors.Is(re.ErrorType, authguest.ErrAlreadyRedeemed): + status, errorType = http.StatusConflict, "tokenAlreadyRedeemed" + case errors.Is(re.ErrorType, authguest.ErrShareNotFound): + status, errorType = http.StatusNotFound, "shareNotFound" + case errors.Is(re.ErrorType, authguest.ErrShareExpired): + status, errorType = http.StatusGone, "shareExpired" + } + + message := re.ErrorType.Error() + if errorType == "internalError" { + message = "An internal error occurred." + } + + writeError(w, status, errorResponse{ErrorType: errorType, Message: message, PermissionID: re.ShareID}) +} diff --git a/services/auth-guest/pkg/server/http/option.go b/services/auth-guest/pkg/server/http/option.go new file mode 100644 index 0000000000..fc4332ca8b --- /dev/null +++ b/services/auth-guest/pkg/server/http/option.go @@ -0,0 +1,71 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package http + +import ( + "context" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest" + "github.com/spf13/pflag" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + Logger log.Logger + Context context.Context + Config *config.Config + Service authguest.AuthGuest + Flags []pflag.Flag +} + +// newOptions initializes the available default options. +func newOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// Logger provides a function to set the logger option. +func Logger(val log.Logger) Option { + return func(o *Options) { + o.Logger = val + } +} + +// Context provides a function to set the context option. +func Context(val context.Context) Option { + return func(o *Options) { + o.Context = val + } +} + +// Config provides a function to set the config option. +func Config(val *config.Config) Option { + return func(o *Options) { + o.Config = val + } +} + +// Service provides a function to set the service option. +func Service(val authguest.AuthGuest) Option { + return func(o *Options) { + o.Service = val + } +} + +// Flags provides a function to set the flags option. +func Flags(flags ...pflag.Flag) Option { + return func(o *Options) { + o.Flags = append(o.Flags, flags...) + } +} diff --git a/services/auth-guest/pkg/server/http/redeem.go b/services/auth-guest/pkg/server/http/redeem.go new file mode 100644 index 0000000000..09bd63c5d0 --- /dev/null +++ b/services/auth-guest/pkg/server/http/redeem.go @@ -0,0 +1,54 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package http + +import ( + "encoding/json" + "net/http" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest" +) + +// RedeemRequest is the request body for token redemption. +type RedeemRequest struct { + Token string `json:"token"` +} + +type redeemResponse struct { + PermissionID string `json:"permissionId"` +} + +// RedeemHandler validates the token submitted to the redeem endpoint. +func RedeemHandler(log log.Logger, s authguest.AuthGuest, cfg *config.Config) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + var req RedeemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Debug().Err(err).Msg("request body is malformed") + writeError(w, http.StatusBadRequest, errorResponse{ErrorType: "invalidRequest", Message: "The request body is malformed."}) + return + } + + result, err := s.Redeem(r.Context(), req.Token) + if err != nil { + log.Debug().Err(err).Msg("redeem failed") + writeRedeemError(w, err) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: cfg.JWT.CookieName, + Value: result.SessionToken, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(cfg.JWT.TTL.Seconds()), + }) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(redeemResponse{PermissionID: result.ShareID}) + } +} diff --git a/services/auth-guest/pkg/server/http/redeem_test.go b/services/auth-guest/pkg/server/http/redeem_test.go new file mode 100644 index 0000000000..28a9320601 --- /dev/null +++ b/services/auth-guest/pkg/server/http/redeem_test.go @@ -0,0 +1,144 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package http + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest/mocks" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func newRedeemHandler(t *testing.T, svc authguest.AuthGuest) http.HandlerFunc { + t.Helper() + cfg := &config.Config{ + JWT: config.JWT{ + CookieName: "__Host-oc_guest_session", + TTL: time.Hour, + }, + } + return RedeemHandler(log.NopLogger(), svc, cfg) +} + +func TestRedeemHandler(t *testing.T) { + svcMock := mocks.NewAuthGuest(t) + svcMock.On("Redeem", mock.Anything, "valid-token").Return(&authguest.RedeemResponse{SessionToken: "session-token", ShareID: "share-1"}, nil) + + body, err := json.Marshal(RedeemRequest{Token: "valid-token"}) + require.NoError(t, err) + + rr := httptest.NewRecorder() + newRedeemHandler(t, svcMock)(rr, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))) + + assert.Equal(t, http.StatusOK, rr.Code) + + var cookie *http.Cookie + for _, c := range rr.Result().Cookies() { + if c.Name == "__Host-oc_guest_session" { + cookie = c + } + } + require.NotNil(t, cookie) + assert.Equal(t, "session-token", cookie.Value) + assert.True(t, cookie.HttpOnly) + assert.True(t, cookie.Secure) + assert.Equal(t, "/", cookie.Path) + + var resp redeemResponse + require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) + assert.Equal(t, "share-1", resp.PermissionID) +} + +func TestRedeemHandlerErrorMapping(t *testing.T) { + tests := []struct { + name string + err error + wantStatus int + wantType string + wantPermission string + }{ + { + name: "token expired", + err: &authguest.RedeemError{ErrorType: authguest.ErrExpired, ShareID: "share-1"}, + wantStatus: http.StatusUnauthorized, + wantType: "tokenExpired", + wantPermission: "share-1", + }, + { + name: "token invalid", + err: &authguest.RedeemError{ErrorType: token.ErrInvalidToken}, + wantStatus: http.StatusUnauthorized, + wantType: "tokenInvalid", + }, + { + name: "token not found", + err: &authguest.RedeemError{ErrorType: storage.ErrNotFound}, + wantStatus: http.StatusNotFound, + wantType: "tokenNotFound", + }, + { + name: "token already redeemed", + err: &authguest.RedeemError{ErrorType: authguest.ErrAlreadyRedeemed}, + wantStatus: http.StatusConflict, + wantType: "tokenAlreadyRedeemed", + }, + { + name: "share not found", + err: &authguest.RedeemError{ErrorType: authguest.ErrShareNotFound}, + wantStatus: http.StatusNotFound, + wantType: "shareNotFound", + }, + { + name: "share expired", + err: &authguest.RedeemError{ErrorType: authguest.ErrShareExpired}, + wantStatus: http.StatusGone, + wantType: "shareExpired", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svcMock := mocks.NewAuthGuest(t) + svcMock.On("Redeem", mock.Anything, "token").Return(nil, tt.err) + + body, err := json.Marshal(RedeemRequest{Token: "token"}) + require.NoError(t, err) + + rr := httptest.NewRecorder() + newRedeemHandler(t, svcMock)(rr, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))) + + assert.Equal(t, tt.wantStatus, rr.Code) + + var resp errorResponse + require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) + assert.Equal(t, tt.wantType, resp.ErrorType) + assert.Equal(t, tt.wantPermission, resp.PermissionID) + }) + } +} + +func TestRedeemHandlerMalformedBody(t *testing.T) { + svcMock := mocks.NewAuthGuest(t) + + rr := httptest.NewRecorder() + newRedeemHandler(t, svcMock)(rr, httptest.NewRequest(http.MethodPost, "/", strings.NewReader("not-json"))) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + + var resp errorResponse + require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) + assert.Equal(t, "invalidRequest", resp.ErrorType) +} diff --git a/services/auth-guest/pkg/server/http/server.go b/services/auth-guest/pkg/server/http/server.go new file mode 100644 index 0000000000..8e1b3c892d --- /dev/null +++ b/services/auth-guest/pkg/server/http/server.go @@ -0,0 +1,72 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package http + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/opencloud-eu/opencloud/pkg/cors" + "github.com/opencloud-eu/opencloud/pkg/middleware" + ohttp "github.com/opencloud-eu/opencloud/pkg/service/http" + "github.com/opencloud-eu/opencloud/pkg/version" + "go-micro.dev/v4" +) + +// Server initializes the http service and server. +func Server(opts ...Option) (ohttp.Service, error) { + options := newOptions(opts...) + + newService, err := ohttp.NewService( + ohttp.TLSConfig(options.Config.HTTP.TLS), + ohttp.Logger(options.Logger), + ohttp.Namespace(options.Config.HTTP.Namespace), + ohttp.Name(options.Config.Service.Name), + ohttp.Version(version.GetString()), + ohttp.Address(options.Config.HTTP.Addr), + ohttp.Context(options.Context), + ohttp.Flags(options.Flags...), + ) + if err != nil { + options.Logger.Error(). + Err(err). + Msg("Error initializing http service") + return ohttp.Service{}, err + } + + middlewares := []func(http.Handler) http.Handler{ + chimiddleware.RequestID, + middleware.Version( + options.Config.Service.Name, + version.GetString(), + ), + middleware.Logger( + options.Logger, + ), + middleware.TraceContext, + middleware.Cors( + cors.Logger(options.Logger), + cors.AllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins), + cors.AllowedMethods(options.Config.HTTP.CORS.AllowedMethods), + cors.AllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders), + cors.AllowCredentials(options.Config.HTTP.CORS.AllowCredentials), + ), + } + + mux := chi.NewMux() + mux.Use(middlewares...) + + mux.Route(options.Config.HTTP.Root, func(r chi.Router) { + r.Post("/v1beta1/extensions/org.libregraph/guestLinks/redeem", RedeemHandler(options.Logger, options.Service, options.Config)) + }) + + err = micro.RegisterHandler(newService.Server(), mux) + if err != nil { + options.Logger.Fatal().Err(err).Msg("failed to register the handler") + } + + newService.Init() + return newService, nil +} diff --git a/services/auth-guest/pkg/service/authguest/mocks/auth_guest.go b/services/auth-guest/pkg/service/authguest/mocks/auth_guest.go new file mode 100644 index 0000000000..ed8291ea94 --- /dev/null +++ b/services/auth-guest/pkg/service/authguest/mocks/auth_guest.go @@ -0,0 +1,227 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token" + mock "github.com/stretchr/testify/mock" +) + +// NewAuthGuest creates a new instance of AuthGuest. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAuthGuest(t interface { + mock.TestingT + Cleanup(func()) +}) *AuthGuest { + mock := &AuthGuest{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// AuthGuest is an autogenerated mock type for the AuthGuest type +type AuthGuest struct { + mock.Mock +} + +type AuthGuest_Expecter struct { + mock *mock.Mock +} + +func (_m *AuthGuest) EXPECT() *AuthGuest_Expecter { + return &AuthGuest_Expecter{mock: &_m.Mock} +} + +// CleanupShare provides a mock function for the type AuthGuest +func (_mock *AuthGuest) CleanupShare(shareID string) error { + ret := _mock.Called(shareID) + + if len(ret) == 0 { + panic("no return value specified for CleanupShare") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(shareID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// AuthGuest_CleanupShare_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CleanupShare' +type AuthGuest_CleanupShare_Call struct { + *mock.Call +} + +// CleanupShare is a helper method to define mock.On call +// - shareID string +func (_e *AuthGuest_Expecter) CleanupShare(shareID any) *AuthGuest_CleanupShare_Call { + return &AuthGuest_CleanupShare_Call{Call: _e.mock.On("CleanupShare", shareID)} +} + +func (_c *AuthGuest_CleanupShare_Call) Run(run func(shareID string)) *AuthGuest_CleanupShare_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *AuthGuest_CleanupShare_Call) Return(err error) *AuthGuest_CleanupShare_Call { + _c.Call.Return(err) + return _c +} + +func (_c *AuthGuest_CleanupShare_Call) RunAndReturn(run func(shareID string) error) *AuthGuest_CleanupShare_Call { + _c.Call.Return(run) + return _c +} + +// CreateToken provides a mock function for the type AuthGuest +func (_mock *AuthGuest) CreateToken(ctx context.Context, shareID string) (*token.Token, error) { + ret := _mock.Called(ctx, shareID) + + if len(ret) == 0 { + panic("no return value specified for CreateToken") + } + + var r0 *token.Token + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*token.Token, error)); ok { + return returnFunc(ctx, shareID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *token.Token); ok { + r0 = returnFunc(ctx, shareID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*token.Token) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, shareID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// AuthGuest_CreateToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateToken' +type AuthGuest_CreateToken_Call struct { + *mock.Call +} + +// CreateToken is a helper method to define mock.On call +// - ctx context.Context +// - shareID string +func (_e *AuthGuest_Expecter) CreateToken(ctx any, shareID any) *AuthGuest_CreateToken_Call { + return &AuthGuest_CreateToken_Call{Call: _e.mock.On("CreateToken", ctx, shareID)} +} + +func (_c *AuthGuest_CreateToken_Call) Run(run func(ctx context.Context, shareID string)) *AuthGuest_CreateToken_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *AuthGuest_CreateToken_Call) Return(token1 *token.Token, err error) *AuthGuest_CreateToken_Call { + _c.Call.Return(token1, err) + return _c +} + +func (_c *AuthGuest_CreateToken_Call) RunAndReturn(run func(ctx context.Context, shareID string) (*token.Token, error)) *AuthGuest_CreateToken_Call { + _c.Call.Return(run) + return _c +} + +// Redeem provides a mock function for the type AuthGuest +func (_mock *AuthGuest) Redeem(ctx context.Context, tokenString string) (*authguest.RedeemResponse, error) { + ret := _mock.Called(ctx, tokenString) + + if len(ret) == 0 { + panic("no return value specified for Redeem") + } + + var r0 *authguest.RedeemResponse + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*authguest.RedeemResponse, error)); ok { + return returnFunc(ctx, tokenString) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *authguest.RedeemResponse); ok { + r0 = returnFunc(ctx, tokenString) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*authguest.RedeemResponse) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, tokenString) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// AuthGuest_Redeem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Redeem' +type AuthGuest_Redeem_Call struct { + *mock.Call +} + +// Redeem is a helper method to define mock.On call +// - ctx context.Context +// - tokenString string +func (_e *AuthGuest_Expecter) Redeem(ctx any, tokenString any) *AuthGuest_Redeem_Call { + return &AuthGuest_Redeem_Call{Call: _e.mock.On("Redeem", ctx, tokenString)} +} + +func (_c *AuthGuest_Redeem_Call) Run(run func(ctx context.Context, tokenString string)) *AuthGuest_Redeem_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *AuthGuest_Redeem_Call) Return(redeemResponse *authguest.RedeemResponse, err error) *AuthGuest_Redeem_Call { + _c.Call.Return(redeemResponse, err) + return _c +} + +func (_c *AuthGuest_Redeem_Call) RunAndReturn(run func(ctx context.Context, tokenString string) (*authguest.RedeemResponse, error)) *AuthGuest_Redeem_Call { + _c.Call.Return(run) + return _c +} diff --git a/services/auth-guest/pkg/service/authguest/options.go b/services/auth-guest/pkg/service/authguest/options.go new file mode 100644 index 0000000000..b67b8f585e --- /dev/null +++ b/services/auth-guest/pkg/service/authguest/options.go @@ -0,0 +1,41 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package authguest + +import ( + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" +) + +type Option func(*Options) + +// Options for the auth-guest service +type Options struct { + GatewaySelector pool.Selectable[gateway.GatewayAPIClient] + ServiceAccount config.ServiceAccount + JWT *jwt.JwtService +} + +// GatewaySelector adds a grpc client selector for the gateway service +func GatewaySelector(gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) Option { + return func(o *Options) { + o.GatewaySelector = gatewaySelector + } +} + +// ServiceAccount configures a service account for the auth-guest service +func ServiceAccount(sa config.ServiceAccount) Option { + return func(o *Options) { + o.ServiceAccount = sa + } +} + +// JWT configures the jwt service for the auth-guest service +func JWT(m *jwt.JwtService) Option { + return func(o *Options) { + o.JWT = m + } +} diff --git a/services/auth-guest/pkg/service/authguest/service.go b/services/auth-guest/pkg/service/authguest/service.go new file mode 100644 index 0000000000..c33e016871 --- /dev/null +++ b/services/auth-guest/pkg/service/authguest/service.go @@ -0,0 +1,216 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package authguest + +import ( + "context" + "errors" + "fmt" + "time" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1" + + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" + "github.com/opencloud-eu/reva/v2/pkg/utils" +) + +var ErrExpired = errors.New("token expired") +var ErrAlreadyRedeemed = errors.New("token already redeemed") +var ErrShareNotFound = errors.New("share not found") +var ErrShareExpired = errors.New("share expired") + +const guestLinkTokenTTL = 30 * time.Minute + +// RedeemError wraps a redeem failure together with the share id. The HTTP +// transport inspects ErrorType to choose a status code and message. +type RedeemError struct { + ErrorType error + ShareID string +} + +func (e *RedeemError) Error() string { return e.ErrorType.Error() } + +// RedeemResponse is the result of a successful token redemption. +type RedeemResponse struct { + SessionToken string + ShareID string +} + +// AuthGuest is the domain service used by the transport and event layers. +type AuthGuest interface { + CreateToken(ctx context.Context, shareID string) (*token.Token, error) + Redeem(ctx context.Context, tokenString string) (*RedeemResponse, error) + CleanupShare(shareID string) error +} + +var _ AuthGuest = (*AuthGuestService)(nil) + +// AuthGuestService contains the business logic shared by auth-guest transport services. +type AuthGuestService struct { + tokenSvc *token.TokenService + store storage.Manager + gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + serviceAccount config.ServiceAccount + jwtService *jwt.JwtService +} + +func NewAuthGuestService(tokenSvc *token.TokenService, store storage.Manager, opts ...Option) *AuthGuestService { + o := &Options{} + for _, opt := range opts { + opt(o) + } + + return &AuthGuestService{ + tokenSvc: tokenSvc, + store: store, + gatewaySelector: o.GatewaySelector, + serviceAccount: o.ServiceAccount, + jwtService: o.JWT, + } +} + +func (s *AuthGuestService) CreateToken(ctx context.Context, shareID string) (*token.Token, error) { + tok, err := s.tokenSvc.Generate(shareID) + if err != nil { + return nil, err + } + + if err := s.store.Add(storage.Record{ + ShareID: shareID, + ShareIDHash: tok.ShareIDHash, + SecretHash: tok.SecretHash(), + Expiry: time.Now().Add(guestLinkTokenTTL), + Redeemed: false, + }); err != nil { + return nil, err + } + + return tok, nil +} + +// Redeem validates a token and its share and exchanges them for a session token +// and the share id. +func (s *AuthGuestService) Redeem(ctx context.Context, tokenString string) (*RedeemResponse, error) { + rec, err := s.verifyToken(tokenString) + if err != nil { + return nil, err + } + + if _, err := s.validateShare(ctx, rec.ShareID); err != nil { + return nil, err + } + + if err := s.store.Redeem(rec.ShareIDHash); err != nil { + if errors.Is(err, storage.ErrAlreadyRedeemed) { + return nil, &RedeemError{ErrorType: ErrAlreadyRedeemed, ShareID: rec.ShareID} + } + return nil, err + } + + sessionToken, err := s.jwtService.Sign(rec.ShareID) + if err != nil { + return nil, err + } + + return &RedeemResponse{SessionToken: sessionToken, ShareID: rec.ShareID}, nil +} + +// CleanupShare removes a share's token record from storage. Missing records are ignored. +func (s *AuthGuestService) CleanupShare(shareID string) error { + shareIDHash := token.Hash(shareID) + err := s.store.Remove(shareIDHash) + if err != nil && err != storage.ErrNotFound { + return err + } + + return nil +} + +// VerifyToken validates a token and returns its stored record. +func (s *AuthGuestService) verifyToken(tokenString string) (*storage.Record, error) { + tok, err := s.tokenSvc.Parse(tokenString) + if err != nil { + return nil, &RedeemError{ErrorType: err} + } + + rec, err := s.store.Get(tok.ShareIDHash) + if err != nil { + return nil, &RedeemError{ErrorType: err} + } + + if err := s.tokenSvc.Verify(*tok, rec.SecretHash); err != nil { + return nil, &RedeemError{ErrorType: err, ShareID: rec.ShareID} + } + + if !rec.Expiry.IsZero() && rec.Expiry.Before(time.Now()) { + return nil, &RedeemError{ErrorType: ErrExpired, ShareID: rec.ShareID} + } + + if rec.Redeemed { + return nil, &RedeemError{ErrorType: ErrAlreadyRedeemed, ShareID: rec.ShareID} + } + + return &rec, nil +} + +// validateShare extracts the share information from the gateway and checks its existence and expiration. +func (s *AuthGuestService) validateShare(ctx context.Context, shareID string) (*collaboration.Share, error) { + share, err := s.getShare(ctx, shareID) + if err != nil { + return nil, &RedeemError{ErrorType: err, ShareID: shareID} + } + + if exp := utils.TSToTime(share.GetExpiration()); !exp.IsZero() && exp.Before(time.Now()) { + return nil, &RedeemError{ErrorType: ErrShareExpired, ShareID: shareID} + } + + return share, nil +} + +// getShare fetches a share from the gateway. +func (s *AuthGuestService) getShare(ctx context.Context, shareID string) (*collaboration.Share, error) { + gwc, err := s.gatewaySelector.Next() + if err != nil { + return nil, err + } + + ctx, err = utils.GetServiceUserContextWithContext(ctx, gwc, s.serviceAccount.ServiceAccountID, s.serviceAccount.ServiceAccountSecret) + if err != nil { + return nil, err + } + + resp, err := gwc.GetShare(ctx, &collaboration.GetShareRequest{ + Ref: &collaboration.ShareReference{ + Spec: &collaboration.ShareReference_Id{ + Id: &collaboration.ShareId{ + OpaqueId: shareID, + }, + }, + }, + }) + if err != nil { + return nil, err + } + + switch resp.GetStatus().GetCode() { + case rpc.Code_CODE_OK: + case rpc.Code_CODE_NOT_FOUND: + return nil, ErrShareNotFound + default: + return nil, fmt.Errorf("could not get share %s: %s", shareID, resp.GetStatus().GetMessage()) + } + + share := resp.GetShare() + if share == nil { + return nil, ErrShareNotFound + } + + return share, nil +} diff --git a/services/auth-guest/pkg/service/authguest/service_test.go b/services/auth-guest/pkg/service/authguest/service_test.go new file mode 100644 index 0000000000..df033dc596 --- /dev/null +++ b/services/auth-guest/pkg/service/authguest/service_test.go @@ -0,0 +1,256 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package authguest + +import ( + "context" + "testing" + "time" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage" + storagemocks "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage/mocks" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" + "github.com/opencloud-eu/reva/v2/pkg/utils" + cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const testShareID = "e0123456-7890-abcd-ef01-234567890abc" + +type gatewayTestSelector struct { + client gateway.GatewayAPIClient +} + +func (s gatewayTestSelector) Next(...pool.Option) (gateway.GatewayAPIClient, error) { + return s.client, nil +} + +func newGatewayTestSelector(client gateway.GatewayAPIClient) pool.Selectable[gateway.GatewayAPIClient] { + return gatewayTestSelector{client: client} +} + +func newGatewayMock(resp *collaboration.GetShareResponse) *cs3mocks.GatewayAPIClient { + gwc := &cs3mocks.GatewayAPIClient{} + gwc.On("Authenticate", mock.Anything, mock.Anything). + Return(&gateway.AuthenticateResponse{ + Status: &rpc.Status{Code: rpc.Code_CODE_OK}, + Token: "token", + }, nil) + gwc.On("GetShare", mock.Anything, mock.Anything).Return(resp, nil) + return gwc +} + +func newToken(t *testing.T) (string, storage.Record) { + ts := token.NewTokenService() + tok, err := ts.Generate(testShareID) + require.NoError(t, err) + + rec := storage.Record{ + ShareID: testShareID, + ShareIDHash: tok.ShareIDHash, + SecretHash: tok.SecretHash(), + Expiry: time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC), + } + + return tok.String(), rec +} + +func newShareService(t *testing.T, gwc *cs3mocks.GatewayAPIClient) *AuthGuestService { + t.Helper() + return NewAuthGuestService( + token.NewTokenService(), + storagemocks.NewManager(t), + GatewaySelector(newGatewayTestSelector(gwc)), + ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}), + ) +} + +func newRedeemService(t *testing.T, store storage.Manager, gwc *cs3mocks.GatewayAPIClient) *AuthGuestService { + t.Helper() + return NewAuthGuestService( + token.NewTokenService(), + store, + GatewaySelector(newGatewayTestSelector(gwc)), + ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}), + JWT(jwt.NewJwtService("test-secret", time.Hour)), + ) +} + +func TestCreateTokenPersistsRecord(t *testing.T) { + store := storagemocks.NewManager(t) + expiry := time.Date(2027, 1, 2, 3, 4, 5, 0, time.UTC) + gwc := newGatewayMock(&collaboration.GetShareResponse{ + Status: &rpc.Status{Code: rpc.Code_CODE_OK}, + Share: &collaboration.Share{ + Id: &collaboration.ShareId{OpaqueId: testShareID}, + Expiration: utils.TimeToTS(expiry), + }, + }) + + var added storage.Record + store.On("Add", mock.Anything).Run(func(args mock.Arguments) { + added = args.Get(0).(storage.Record) + }).Return(nil) + + s := NewAuthGuestService( + token.NewTokenService(), + store, + GatewaySelector(newGatewayTestSelector(gwc)), + ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}), + ) + + tok, err := s.CreateToken(context.Background(), testShareID) + require.NoError(t, err) + + store.AssertCalled(t, "Add", mock.Anything) + assert.Equal(t, testShareID, added.ShareID) + assert.Equal(t, tok.ShareIDHash, added.ShareIDHash) + assert.Equal(t, tok.SecretHash(), added.SecretHash) + assert.WithinDuration(t, time.Now().Add(guestLinkTokenTTL), added.Expiry, time.Minute) + assert.False(t, added.Redeemed) +} + +func TestVerifyToken(t *testing.T) { + tests := []struct { + name string + expired bool + redeemed bool + wantErr error + }{ + {name: "valid"}, + {name: "expired", expired: true, wantErr: ErrExpired}, + {name: "already redeemed", redeemed: true, wantErr: ErrAlreadyRedeemed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := storagemocks.NewManager(t) + s := NewAuthGuestService(token.NewTokenService(), store) + tok, rec := newToken(t) + if tt.expired { + rec.Expiry = time.Now().Add(-time.Hour) + } + if tt.redeemed { + rec.Redeemed = true + } + store.On("Get", rec.ShareIDHash).Return(rec, nil) + + got, err := s.verifyToken(tok) + if tt.wantErr != nil { + var re *RedeemError + require.ErrorAs(t, err, &re) + assert.ErrorIs(t, re.ErrorType, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, rec, *got) + }) + } +} + +func TestValidateShare(t *testing.T) { + share := &collaboration.Share{Id: &collaboration.ShareId{OpaqueId: testShareID}} + notExpiredShare := &collaboration.Share{ + Id: &collaboration.ShareId{OpaqueId: testShareID}, + Expiration: utils.TimeToTS(time.Now().Add(time.Hour)), + } + expiredShare := &collaboration.Share{ + Id: &collaboration.ShareId{OpaqueId: testShareID}, + Expiration: utils.TimeToTS(time.Now().Add(-time.Hour)), + } + + tests := []struct { + name string + response *collaboration.GetShareResponse + wantErr error + }{ + { + name: "valid", + response: &collaboration.GetShareResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, Share: share}, + }, + { + name: "not expired", + response: &collaboration.GetShareResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, Share: notExpiredShare}, + }, + { + name: "expired", + response: &collaboration.GetShareResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, Share: expiredShare}, + wantErr: ErrShareExpired, + }, + { + name: "not found", + response: &collaboration.GetShareResponse{Status: &rpc.Status{Code: rpc.Code_CODE_NOT_FOUND}}, + wantErr: ErrShareNotFound, + }, + { + name: "nil share", + response: &collaboration.GetShareResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}}, + wantErr: ErrShareNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newShareService(t, newGatewayMock(tt.response)) + + got, err := s.validateShare(context.Background(), testShareID) + if tt.wantErr != nil { + var re *RedeemError + require.ErrorAs(t, err, &re) + assert.ErrorIs(t, re.ErrorType, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.response.GetShare(), got) + }) + } +} + +func TestRedeem(t *testing.T) { + store := storagemocks.NewManager(t) + tok, rec := newToken(t) + store.On("Get", rec.ShareIDHash).Return(rec, nil) + store.On("Redeem", rec.ShareIDHash).Return(nil) + + share := &collaboration.Share{Id: &collaboration.ShareId{OpaqueId: testShareID}} + s := newRedeemService(t, store, newGatewayMock(&collaboration.GetShareResponse{ + Status: &rpc.Status{Code: rpc.Code_CODE_OK}, + Share: share, + })) + + result, err := s.Redeem(context.Background(), tok) + require.NoError(t, err) + require.NotEmpty(t, result.SessionToken) + assert.Equal(t, testShareID, result.ShareID) + + store.AssertCalled(t, "Redeem", rec.ShareIDHash) +} + +func TestRedeemAlreadyRedeemed(t *testing.T) { + store := storagemocks.NewManager(t) + tok, rec := newToken(t) + store.On("Get", rec.ShareIDHash).Return(rec, nil) + store.On("Redeem", rec.ShareIDHash).Return(storage.ErrAlreadyRedeemed) + + share := &collaboration.Share{Id: &collaboration.ShareId{OpaqueId: testShareID}} + s := newRedeemService(t, store, newGatewayMock(&collaboration.GetShareResponse{ + Status: &rpc.Status{Code: rpc.Code_CODE_OK}, + Share: share, + })) + + _, err := s.Redeem(context.Background(), tok) + var re *RedeemError + require.ErrorAs(t, err, &re) + assert.ErrorIs(t, re.ErrorType, ErrAlreadyRedeemed) +} diff --git a/services/auth-guest/pkg/service/events/handlers.go b/services/auth-guest/pkg/service/events/handlers.go new file mode 100644 index 0000000000..bdce9f0ec8 --- /dev/null +++ b/services/auth-guest/pkg/service/events/handlers.go @@ -0,0 +1,69 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package events + +import ( + "context" + "time" + + user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + ocEvents "github.com/opencloud-eu/opencloud/pkg/events" + "github.com/opencloud-eu/reva/v2/pkg/events" +) + +// handleShareCreated handles a share created event. +func (s *EventConsumer) handleShareCreated(ctx context.Context, ev events.ShareCreated) error { + _, span := tracer.Start(ctx, "handleShareCreated") + defer span.End() + + if ev.GranteeUserID == nil || ev.GranteeUserID.GetType() != user.UserType_USER_TYPE_GUEST { + s.log.Debug().Msg("share created event is not for a guest, skipping") + return nil + } + + tok, err := s.authGuest.CreateToken(ctx, ev.ShareID.GetOpaqueId()) + if err != nil { + return err + } + + return events.Publish(ctx, s.stream, ocEvents.GuestTokenCreated{ + ShareID: ev.ShareID, + Sharer: ev.Sharer, + GranteeEmail: ev.GranteeUserID.GetOpaqueId(), + ItemID: ev.ItemID, + ResourceName: ev.ResourceName, + Token: tok.String(), + Timestamp: time.Now(), + }) +} + +// handleShareRemoved handles a share removed event. +func (s *EventConsumer) handleShareRemoved(ctx context.Context, ev events.ShareRemoved) error { + _, span := tracer.Start(ctx, "handleShareRemoved") + defer span.End() + + if ev.GranteeUserID == nil || ev.GranteeUserID.GetType() != user.UserType_USER_TYPE_GUEST { + s.log.Debug().Msg("share removed event is not for a guest, skipping") + return nil + } + + s.log.Debug().Interface("event", ev).Msg("share removed event received") + + return s.authGuest.CleanupShare(ev.ShareID.GetOpaqueId()) +} + +// handleShareExpired handles a share expired event. +func (s *EventConsumer) handleShareExpired(ctx context.Context, ev events.ShareExpired) error { + _, span := tracer.Start(ctx, "handleShareExpired") + defer span.End() + + if ev.GranteeUserID == nil || ev.GranteeUserID.GetType() != user.UserType_USER_TYPE_GUEST { + s.log.Debug().Msg("share expired event is not for a guest, skipping") + return nil + } + + s.log.Debug().Interface("event", ev).Msg("share expired event received") + + return s.authGuest.CleanupShare(ev.ShareID.GetOpaqueId()) +} diff --git a/services/auth-guest/pkg/service/events/handlers_test.go b/services/auth-guest/pkg/service/events/handlers_test.go new file mode 100644 index 0000000000..987336b87b --- /dev/null +++ b/services/auth-guest/pkg/service/events/handlers_test.go @@ -0,0 +1,151 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package events + +import ( + "context" + "testing" + + user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + ocEvents "github.com/opencloud-eu/opencloud/pkg/events" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest/mocks" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token" + "github.com/opencloud-eu/reva/v2/pkg/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + microevents "go-micro.dev/v4/events" +) + +const testShareID = "e0123456-7890-abcd-ef01-234567890abc" + +type testBus struct { + published []any +} + +func (tb *testBus) Publish(_ string, ev any, _ ...microevents.PublishOption) error { + tb.published = append(tb.published, ev) + return nil +} + +func (tb *testBus) Consume(_ string, _ ...microevents.ConsumeOption) (<-chan microevents.Event, error) { + return nil, nil +} + +func newConsumer(t *testing.T, authGuest authguest.AuthGuest) (*EventConsumer, *testBus) { + t.Helper() + bus := &testBus{} + consumer, err := NewEventConsumer(bus, AuthGuestService(authGuest)) + require.NoError(t, err) + + return consumer, bus +} + +func TestHandleShareCreated(t *testing.T) { + tok, err := token.NewTokenService().Generate(testShareID) + require.NoError(t, err) + + svcMock := mocks.NewAuthGuest(t) + svcMock.On("CreateToken", mock.Anything, testShareID).Return(tok, nil) + + svc, bus := newConsumer(t, svcMock) + + ev := events.ShareCreated{ + ShareID: &collaboration.ShareId{OpaqueId: testShareID}, + Sharer: &user.UserId{OpaqueId: "sharer"}, + ItemID: &provider.ResourceId{StorageId: "storage", OpaqueId: "item"}, + ResourceName: "resource", + GranteeUserID: &user.UserId{OpaqueId: "guest@example.org", Type: user.UserType_USER_TYPE_GUEST}, + } + + require.NoError(t, svc.handleShareCreated(context.Background(), ev)) + + svcMock.AssertCalled(t, "CreateToken", mock.Anything, testShareID) + + require.Len(t, bus.published, 1) + published, ok := bus.published[0].(ocEvents.GuestTokenCreated) + require.True(t, ok) + assert.Equal(t, testShareID, published.ShareID.GetOpaqueId()) + assert.Equal(t, ev.Sharer, published.Sharer) + assert.Equal(t, "guest@example.org", published.GranteeEmail) + assert.Equal(t, ev.ItemID, published.ItemID) + assert.Equal(t, ev.ResourceName, published.ResourceName) + assert.Equal(t, tok.String(), published.Token) +} + +func TestHandleShareCreatedSkipsNonGuest(t *testing.T) { + svcMock := mocks.NewAuthGuest(t) + svc, bus := newConsumer(t, svcMock) + + ev := events.ShareCreated{ + ShareID: &collaboration.ShareId{OpaqueId: testShareID}, + GranteeUserID: &user.UserId{OpaqueId: "user", Type: user.UserType_USER_TYPE_PRIMARY}, + } + + require.NoError(t, svc.handleShareCreated(context.Background(), ev)) + + svcMock.AssertNotCalled(t, "CreateToken", mock.Anything, mock.Anything) + assert.Empty(t, bus.published) +} + +func TestHandleShareRemoved(t *testing.T) { + svcMock := mocks.NewAuthGuest(t) + svcMock.On("CleanupShare", testShareID).Return(nil) + svc, _ := newConsumer(t, svcMock) + + ev := events.ShareRemoved{ + ShareID: &collaboration.ShareId{OpaqueId: testShareID}, + GranteeUserID: &user.UserId{OpaqueId: "guest@example.org", Type: user.UserType_USER_TYPE_GUEST}, + } + + require.NoError(t, svc.handleShareRemoved(context.Background(), ev)) + + svcMock.AssertCalled(t, "CleanupShare", testShareID) +} + +func TestHandleShareRemovedSkipsNonGuest(t *testing.T) { + svcMock := mocks.NewAuthGuest(t) + svc, _ := newConsumer(t, svcMock) + + ev := events.ShareRemoved{ + ShareID: &collaboration.ShareId{OpaqueId: testShareID}, + GranteeUserID: &user.UserId{OpaqueId: "user", Type: user.UserType_USER_TYPE_PRIMARY}, + } + + require.NoError(t, svc.handleShareRemoved(context.Background(), ev)) + + svcMock.AssertNotCalled(t, "CleanupShare", mock.Anything) +} + +func TestHandleShareExpired(t *testing.T) { + svcMock := mocks.NewAuthGuest(t) + svcMock.On("CleanupShare", testShareID).Return(nil) + svc, _ := newConsumer(t, svcMock) + + ev := events.ShareExpired{ + ShareID: &collaboration.ShareId{OpaqueId: testShareID}, + GranteeUserID: &user.UserId{OpaqueId: "guest@example.org", Type: user.UserType_USER_TYPE_GUEST}, + } + + require.NoError(t, svc.handleShareExpired(context.Background(), ev)) + + svcMock.AssertCalled(t, "CleanupShare", testShareID) +} + +func TestHandleShareExpiredSkipsNonGuest(t *testing.T) { + svcMock := mocks.NewAuthGuest(t) + svc, _ := newConsumer(t, svcMock) + + ev := events.ShareExpired{ + ShareID: &collaboration.ShareId{OpaqueId: testShareID}, + GranteeUserID: &user.UserId{OpaqueId: "user", Type: user.UserType_USER_TYPE_PRIMARY}, + } + + require.NoError(t, svc.handleShareExpired(context.Background(), ev)) + + svcMock.AssertNotCalled(t, "CleanupShare", mock.Anything) +} diff --git a/services/auth-guest/pkg/service/events/options.go b/services/auth-guest/pkg/service/events/options.go new file mode 100644 index 0000000000..d9c7c0eacb --- /dev/null +++ b/services/auth-guest/pkg/service/events/options.go @@ -0,0 +1,67 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package events + +import ( + "context" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest" + "github.com/opencloud-eu/reva/v2/pkg/events" +) + +// Option for the auth-guest service +type Option func(*Options) + +// Options for the auth-guest service +type Options struct { + Context context.Context + Logger log.Logger + Stream events.Stream + RegisteredEvents []events.Unmarshaller + NumConsumers int + AuthGuestService authguest.AuthGuest +} + +// Context configures a context for the auth-guest service +func Context(ctx context.Context) Option { + return func(o *Options) { + o.Context = ctx + } +} + +// Logger configures a logger for the auth-guest service +func Logger(log log.Logger) Option { + return func(o *Options) { + o.Logger = log + } +} + +// Stream configures an event stream for the auth-guest service +func Stream(s events.Stream) Option { + return func(o *Options) { + o.Stream = s + } +} + +// RegisteredEvents registers the events the service should listen to +func RegisteredEvents(e []events.Unmarshaller) Option { + return func(o *Options) { + o.RegisteredEvents = e + } +} + +// NumConsumers configures the amount of concurrent event consumers +func NumConsumers(num int) Option { + return func(o *Options) { + o.NumConsumers = num + } +} + +// AuthGuestService configures the guest auth domain service. +func AuthGuestService(s authguest.AuthGuest) Option { + return func(o *Options) { + o.AuthGuestService = s + } +} diff --git a/services/auth-guest/pkg/service/events/service.go b/services/auth-guest/pkg/service/events/service.go new file mode 100644 index 0000000000..6e346febf3 --- /dev/null +++ b/services/auth-guest/pkg/service/events/service.go @@ -0,0 +1,146 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package events + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest" + "github.com/opencloud-eu/reva/v2/pkg/events" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" +) + +var tracer trace.Tracer + +func init() { + tracer = otel.Tracer("github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/events") +} + +var ( + _numConsumersDefault = 1 +) + +// EventConsumer consumes guest share events. +type EventConsumer struct { + ctx context.Context + log log.Logger + stream events.Stream + + authGuest authguest.AuthGuest + + numConsumers int + + events []events.Unmarshaller + + stopCh chan struct{} + stopped *atomic.Bool +} + +// NewEventConsumer creates a new event consumer. +func NewEventConsumer(stream events.Stream, opts ...Option) (*EventConsumer, error) { + o := &Options{ + NumConsumers: _numConsumersDefault, + } + for _, opt := range opts { + opt(o) + } + + s := &EventConsumer{ + ctx: o.Context, + log: o.Logger, + stream: stream, + authGuest: o.AuthGuestService, + events: o.RegisteredEvents, + numConsumers: o.NumConsumers, + stopCh: make(chan struct{}, 1), + stopped: new(atomic.Bool), + } + + return s, nil +} + +// Run to fulfil Runner interface +func (s *EventConsumer) Run() error { + ch, err := events.Consume(s.stream, "auth-guest", s.events...) + if err != nil { + return err + } + + var wg sync.WaitGroup + ctx, cancel := context.WithCancel(s.ctx) + defer cancel() + + s.log.Debug().Int("worker.count", s.numConsumers). + Str("messaging.consumer.group.name", "auth-guest"). + Str("messaging.system", "nats"). + Str("messaging.operation.name", "receive"). + Msg("starting event processing workers") + + // start workers + for i := range s.numConsumers { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case e, ok := <-ch: + if !ok { + return + } + if err := s.processEvent(e); err != nil { + s.log.Error().Err(err). + Int("worker", workerID). + Interface("event", e). + Msg("failed to process event") + } + } + } + }(i) + } + + // wait for stop signal + <-s.stopCh + cancel() // signal workers to stop + wg.Wait() + + return nil +} + +// Close will make the service to stop processing, so the `Run` +// method can finish. +func (s *EventConsumer) Close() { + if s.stopped.CompareAndSwap(false, true) { + close(s.stopCh) + } +} + +// processEvent dispatches an event to the matching handler. +func (s *EventConsumer) processEvent(e events.Event) error { + ctx := e.GetTraceContext(s.ctx) + ctx, span := tracer.Start(ctx, "processEvent") + defer span.End() + + s.log.Debug().Interface("event", e).Msg("processing event") + + switch ev := e.Event.(type) { + case events.ShareCreated: + return s.handleShareCreated(ctx, ev) + case events.ShareRemoved: + return s.handleShareRemoved(ctx, ev) + case events.ShareExpired: + return s.handleShareExpired(ctx, ev) + default: + s.log.Warn(). + Str("eventtype", e.Type). + Msg("unhandled event") + } + + return nil +} diff --git a/services/auth-guest/pkg/service/jwt/jwt.go b/services/auth-guest/pkg/service/jwt/jwt.go new file mode 100644 index 0000000000..1c6dc0c45d --- /dev/null +++ b/services/auth-guest/pkg/service/jwt/jwt.go @@ -0,0 +1,37 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type jwtClaims struct { + ShareID string `json:"permissionId"` + jwt.RegisteredClaims +} + +type JwtService struct { + secret []byte + ttl time.Duration +} + +func NewJwtService(secret string, ttl time.Duration) *JwtService { + return &JwtService{secret: []byte(secret), ttl: ttl} +} + +// Sign returns a signed jwt token for the given share. +func (m *JwtService) Sign(shareID string) (string, error) { + now := time.Now() + claims := jwtClaims{ + ShareID: shareID, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(m.ttl)), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret) +} diff --git a/services/auth-guest/pkg/service/jwt/jwt_test.go b/services/auth-guest/pkg/service/jwt/jwt_test.go new file mode 100644 index 0000000000..2ec12302c2 --- /dev/null +++ b/services/auth-guest/pkg/service/jwt/jwt_test.go @@ -0,0 +1,44 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func parseClaims(t *testing.T, m *JwtService, tokenString string) (*jwtClaims, error) { + t.Helper() + c := &jwtClaims{} + _, err := jwt.ParseWithClaims(tokenString, c, func(*jwt.Token) (any, error) { + return m.secret, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) + return c, err +} + +func TestSignAndParse(t *testing.T) { + m := NewJwtService("test-secret", time.Hour) + + tok, err := m.Sign("share-id") + require.NoError(t, err) + require.NotEmpty(t, tok) + + claims, err := parseClaims(t, m, tok) + require.NoError(t, err) + assert.Equal(t, "share-id", claims.ShareID) +} + +func TestParseExpired(t *testing.T) { + m := NewJwtService("test-secret", -time.Minute) + + tok, err := m.Sign("share-id") + require.NoError(t, err) + + _, err = parseClaims(t, m, tok) + assert.Error(t, err) +} diff --git a/services/auth-guest/pkg/service/storage/file_manager.go b/services/auth-guest/pkg/service/storage/file_manager.go new file mode 100644 index 0000000000..ca65dbffdd --- /dev/null +++ b/services/auth-guest/pkg/service/storage/file_manager.go @@ -0,0 +1,177 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package storage + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/gofrs/flock" + "github.com/google/renameio/v2" +) + +func NewFileManager(root string) *FileManager { + return &FileManager{ + root: root, + } +} + +type FileManager struct { + root string +} + +const dirPerm = 0700 +const filePerm = 0600 + +// minHashLength is the minimum share id hash length needed to derive a path. +const minHashLength = 4 + +func (s *FileManager) Add(rec Record) error { + lock, err := s.lockRecord(rec.ShareIDHash) + if err != nil { + return err + } + defer func() { _ = lock.Unlock() }() + + p, err := s.path(rec.ShareIDHash) + if err != nil { + return err + } + + if _, err := os.Stat(p); err == nil { + return fmt.Errorf("record %q already exists: %w", rec.ShareIDHash, fs.ErrExist) + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + + return s.add(rec) +} + +// Get returns the record for the given share id hash. +func (s *FileManager) Get(shareIDHash string) (Record, error) { + return s.get(shareIDHash) +} + +func (s *FileManager) Remove(shareIDHash string) error { + lock, err := s.lockRecord(shareIDHash) + if err != nil { + return err + } + defer func() { + _ = lock.Unlock() + _ = os.Remove(lock.Path()) + }() + + p, err := s.path(shareIDHash) + if err != nil { + return err + } + + if err := os.Remove(p); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return ErrNotFound + } + return err + } + + return nil +} + +func (s *FileManager) Redeem(shareIDHash string) error { + lock, err := s.lockRecord(shareIDHash) + if err != nil { + return err + } + defer func() { _ = lock.Unlock() }() + + rec, err := s.get(shareIDHash) + if err != nil { + return err + } + + if rec.Redeemed { + return ErrAlreadyRedeemed + } + + rec.Redeemed = true + return s.add(rec) +} + +func (s *FileManager) lockRecord(shareIDHash string) (*flock.Flock, error) { + p, err := s.path(shareIDHash) + if err != nil { + return nil, err + } + + if err := os.MkdirAll(filepath.Dir(p), dirPerm); err != nil { + return nil, fmt.Errorf("could not create directory %s: %w", filepath.Dir(p), err) + } + + lock := flock.New(p + ".lock") + if err := lock.Lock(); err != nil { + return nil, err + } + + return lock, nil +} + +func (s *FileManager) add(rec Record) error { + p, err := s.path(rec.ShareIDHash) + if err != nil { + return err + } + + data, err := json.Marshal(rec) + if err != nil { + return err + } + + dir := filepath.Dir(p) + if err := os.MkdirAll(dir, dirPerm); err != nil { + return fmt.Errorf("could not create directory %s: %w", dir, err) + } + + return renameio.WriteFile(p, data, filePerm) +} + +func (s *FileManager) get(shareIDHash string) (Record, error) { + p, err := s.path(shareIDHash) + if err != nil { + return Record{}, err + } + + data, err := os.ReadFile(p) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return Record{}, ErrNotFound + } + return Record{}, err + } + + rec := Record{} + if err := json.Unmarshal(data, &rec); err != nil { + return Record{}, err + } + + return rec, nil +} + +func (s *FileManager) path(shareIDHash string) (string, error) { + if len(shareIDHash) < minHashLength { + return "", ErrInvalidHash + } + + p := filepath.Join(s.root, shareIDHash[:2], shareIDHash[2:4], shareIDHash[4:]+".json") + root := filepath.Clean(s.root) + if !strings.HasPrefix(p, root+string(os.PathSeparator)) { + return "", ErrInvalidHash + } + + return p, nil +} diff --git a/services/auth-guest/pkg/service/storage/file_manager_test.go b/services/auth-guest/pkg/service/storage/file_manager_test.go new file mode 100644 index 0000000000..737f505000 --- /dev/null +++ b/services/auth-guest/pkg/service/storage/file_manager_test.go @@ -0,0 +1,170 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package storage + +import ( + "io/fs" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newRecord(shareID string) Record { + svc := token.NewTokenService() + tok, _ := svc.Generate(shareID) + + return Record{ + ShareID: shareID, + ShareIDHash: tok.ShareIDHash, + SecretHash: tok.SecretHash(), + Expiry: time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC), + } +} + +func TestFileManagerAddGet(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + rec := newRecord("e0123456-7890-abcd-ef01-234567890abc") + require.NoError(t, s.Add(rec)) + + got, err := s.Get(rec.ShareIDHash) + require.NoError(t, err) + assert.Equal(t, rec, got) +} + +func TestFileManagerGetMissing(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + _, err := s.Get("doesnotexist") + assert.ErrorIs(t, err, ErrNotFound) +} + +func TestFileManagerAddExisting(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + rec := newRecord("e0123456-7890-abcd-ef01-234567890abc") + require.NoError(t, s.Add(rec)) + + rec.SecretHash = "other" + require.ErrorIs(t, s.Add(rec), fs.ErrExist) +} + +func TestFileManagerInvalidHash(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + _, err := s.Get("ab") + require.ErrorIs(t, err, ErrInvalidHash) + + _, err = s.Get("../../etc/passwd-xyz") + require.ErrorIs(t, err, ErrInvalidHash) + + require.ErrorIs(t, s.Remove("ab"), ErrInvalidHash) + require.ErrorIs(t, s.Add(Record{ShareIDHash: "ab"}), ErrInvalidHash) +} + +func TestFileManagerRemove(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + rec := newRecord("e0123456-7890-abcd-ef01-234567890abc") + require.NoError(t, s.Add(rec)) + + require.NoError(t, s.Remove(rec.ShareIDHash)) + + _, err := s.Get(rec.ShareIDHash) + assert.ErrorIs(t, err, ErrNotFound) +} + +func TestFileManagerRemoveMissing(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + err := s.Remove("doesnotexist") + assert.ErrorIs(t, err, ErrNotFound) +} + +func TestFileManagerRedeem(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + rec := newRecord("e0123456-7890-abcd-ef01-234567890abc") + require.NoError(t, s.Add(rec)) + + require.NoError(t, s.Redeem(rec.ShareIDHash)) + + got, err := s.Get(rec.ShareIDHash) + require.NoError(t, err) + assert.True(t, got.Redeemed) + + err = s.Redeem(rec.ShareIDHash) + assert.ErrorIs(t, err, ErrAlreadyRedeemed) +} + +func TestFileManagerRedeemMissing(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + err := s.Redeem("doesnotexist") + assert.ErrorIs(t, err, ErrNotFound) +} + +func TestFileManagerAddConcurrent(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + rec := newRecord("e0123456-7890-abcd-ef01-234567890abc") + + const workers = 20 + var ( + wg sync.WaitGroup + success atomic.Int32 + ) + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + if err := s.Add(rec); err == nil { + success.Add(1) + } + }() + } + wg.Wait() + + assert.Equal(t, int32(1), success.Load()) +} + +func TestFileManagerRedeemConcurrent(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + rec := newRecord("e0123456-7890-abcd-ef01-234567890abc") + require.NoError(t, s.Add(rec)) + + const workers = 20 + var ( + wg sync.WaitGroup + success atomic.Int32 + ) + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + if err := s.Redeem(rec.ShareIDHash); err == nil { + success.Add(1) + } + }() + } + wg.Wait() + + assert.Equal(t, int32(1), success.Load()) +} diff --git a/services/auth-guest/pkg/service/storage/mocks/manager.go b/services/auth-guest/pkg/service/storage/mocks/manager.go new file mode 100644 index 0000000000..674c553c58 --- /dev/null +++ b/services/auth-guest/pkg/service/storage/mocks/manager.go @@ -0,0 +1,250 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage" + mock "github.com/stretchr/testify/mock" +) + +// NewManager creates a new instance of Manager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewManager(t interface { + mock.TestingT + Cleanup(func()) +}) *Manager { + mock := &Manager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// Manager is an autogenerated mock type for the Manager type +type Manager struct { + mock.Mock +} + +type Manager_Expecter struct { + mock *mock.Mock +} + +func (_m *Manager) EXPECT() *Manager_Expecter { + return &Manager_Expecter{mock: &_m.Mock} +} + +// Add provides a mock function for the type Manager +func (_mock *Manager) Add(rec storage.Record) error { + ret := _mock.Called(rec) + + if len(ret) == 0 { + panic("no return value specified for Add") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(storage.Record) error); ok { + r0 = returnFunc(rec) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Manager_Add_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Add' +type Manager_Add_Call struct { + *mock.Call +} + +// Add is a helper method to define mock.On call +// - rec storage.Record +func (_e *Manager_Expecter) Add(rec any) *Manager_Add_Call { + return &Manager_Add_Call{Call: _e.mock.On("Add", rec)} +} + +func (_c *Manager_Add_Call) Run(run func(rec storage.Record)) *Manager_Add_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 storage.Record + if args[0] != nil { + arg0 = args[0].(storage.Record) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *Manager_Add_Call) Return(err error) *Manager_Add_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Manager_Add_Call) RunAndReturn(run func(rec storage.Record) error) *Manager_Add_Call { + _c.Call.Return(run) + return _c +} + +// Get provides a mock function for the type Manager +func (_mock *Manager) Get(shareIDHash string) (storage.Record, error) { + ret := _mock.Called(shareIDHash) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 storage.Record + var r1 error + if returnFunc, ok := ret.Get(0).(func(string) (storage.Record, error)); ok { + return returnFunc(shareIDHash) + } + if returnFunc, ok := ret.Get(0).(func(string) storage.Record); ok { + r0 = returnFunc(shareIDHash) + } else { + r0 = ret.Get(0).(storage.Record) + } + if returnFunc, ok := ret.Get(1).(func(string) error); ok { + r1 = returnFunc(shareIDHash) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Manager_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type Manager_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - shareIDHash string +func (_e *Manager_Expecter) Get(shareIDHash any) *Manager_Get_Call { + return &Manager_Get_Call{Call: _e.mock.On("Get", shareIDHash)} +} + +func (_c *Manager_Get_Call) Run(run func(shareIDHash string)) *Manager_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *Manager_Get_Call) Return(record storage.Record, err error) *Manager_Get_Call { + _c.Call.Return(record, err) + return _c +} + +func (_c *Manager_Get_Call) RunAndReturn(run func(shareIDHash string) (storage.Record, error)) *Manager_Get_Call { + _c.Call.Return(run) + return _c +} + +// Redeem provides a mock function for the type Manager +func (_mock *Manager) Redeem(shareIDHash string) error { + ret := _mock.Called(shareIDHash) + + if len(ret) == 0 { + panic("no return value specified for Redeem") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(shareIDHash) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Manager_Redeem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Redeem' +type Manager_Redeem_Call struct { + *mock.Call +} + +// Redeem is a helper method to define mock.On call +// - shareIDHash string +func (_e *Manager_Expecter) Redeem(shareIDHash any) *Manager_Redeem_Call { + return &Manager_Redeem_Call{Call: _e.mock.On("Redeem", shareIDHash)} +} + +func (_c *Manager_Redeem_Call) Run(run func(shareIDHash string)) *Manager_Redeem_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *Manager_Redeem_Call) Return(err error) *Manager_Redeem_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Manager_Redeem_Call) RunAndReturn(run func(shareIDHash string) error) *Manager_Redeem_Call { + _c.Call.Return(run) + return _c +} + +// Remove provides a mock function for the type Manager +func (_mock *Manager) Remove(shareIDHash string) error { + ret := _mock.Called(shareIDHash) + + if len(ret) == 0 { + panic("no return value specified for Remove") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(shareIDHash) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Manager_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove' +type Manager_Remove_Call struct { + *mock.Call +} + +// Remove is a helper method to define mock.On call +// - shareIDHash string +func (_e *Manager_Expecter) Remove(shareIDHash any) *Manager_Remove_Call { + return &Manager_Remove_Call{Call: _e.mock.On("Remove", shareIDHash)} +} + +func (_c *Manager_Remove_Call) Run(run func(shareIDHash string)) *Manager_Remove_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *Manager_Remove_Call) Return(err error) *Manager_Remove_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Manager_Remove_Call) RunAndReturn(run func(shareIDHash string) error) *Manager_Remove_Call { + _c.Call.Return(run) + return _c +} diff --git a/services/auth-guest/pkg/service/storage/storage.go b/services/auth-guest/pkg/service/storage/storage.go new file mode 100644 index 0000000000..b9c83bd72e --- /dev/null +++ b/services/auth-guest/pkg/service/storage/storage.go @@ -0,0 +1,29 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package storage + +import ( + "errors" + "time" +) + +var ErrNotFound = errors.New("record not found") +var ErrAlreadyRedeemed = errors.New("token already redeemed") +var ErrInvalidHash = errors.New("invalid share id hash") + +// Record holds the data persisted for a guest share token. +type Record struct { + ShareID string `json:"shareid"` + ShareIDHash string `json:"shareidhash"` + SecretHash string `json:"secrethash"` + Expiry time.Time `json:"expiry,omitzero"` + Redeemed bool `json:"redeemed"` +} + +type Manager interface { + Add(rec Record) error + Get(shareIDHash string) (Record, error) + Remove(shareIDHash string) error + Redeem(shareIDHash string) error +} diff --git a/services/auth-guest/pkg/service/token/token.go b/services/auth-guest/pkg/service/token/token.go new file mode 100644 index 0000000000..e997845a62 --- /dev/null +++ b/services/auth-guest/pkg/service/token/token.go @@ -0,0 +1,78 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package token + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "strings" +) + +const ( + tokenVersion = "v1" + secretLength = 32 + tokenParts = 3 +) + +var ErrInvalidToken = errors.New("invalid token") + +type Token struct { + ShareIDHash string + secret string +} + +func (t *Token) String() string { + return strings.Join([]string{tokenVersion, t.ShareIDHash, t.secret}, ".") +} + +func (t *Token) SecretHash() string { + return Hash(t.secret) +} + +type TokenService struct{} + +func NewTokenService() *TokenService { + return &TokenService{} +} + +func (s *TokenService) Generate(shareID string) (*Token, error) { + secretBytes := make([]byte, secretLength) + if _, err := rand.Read(secretBytes); err != nil { + return nil, fmt.Errorf("could not generate random secret: %w", err) + } + + secret := base64.RawURLEncoding.EncodeToString(secretBytes) + return &Token{ + ShareIDHash: Hash(shareID), + secret: secret, + }, nil +} + +func (s *TokenService) Parse(encoded string) (*Token, error) { + parts := strings.Split(encoded, ".") + if len(parts) != tokenParts || parts[0] != tokenVersion || parts[1] == "" || parts[2] == "" { + return nil, ErrInvalidToken + } + + return &Token{ + ShareIDHash: parts[1], + secret: parts[2], + }, nil +} + +func (s *TokenService) Verify(candidate Token, storedSecretHash string) error { + if candidate.ShareIDHash == "" || candidate.secret == "" || candidate.SecretHash() != storedSecretHash { + return ErrInvalidToken + } + + return nil +} + +func Hash(value string) string { + h := sha256.Sum256([]byte(value)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} diff --git a/services/auth-guest/pkg/service/token/token_test.go b/services/auth-guest/pkg/service/token/token_test.go new file mode 100644 index 0000000000..f80fbfd882 --- /dev/null +++ b/services/auth-guest/pkg/service/token/token_test.go @@ -0,0 +1,102 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package token + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testShareID = "e0123456-7890-abcd-ef01-234567890abc" + +func TestGenerateAndString(t *testing.T) { + svc := NewTokenService() + + tok, err := svc.Generate(testShareID) + require.NoError(t, err) + assert.Equal(t, Hash(testShareID), tok.ShareIDHash) + assert.NotEmpty(t, tok.SecretHash()) + assert.NotEmpty(t, tok.String()) +} + +func TestGenerateRandomizesSecret(t *testing.T) { + svc := NewTokenService() + + tok1, err := svc.Generate(testShareID) + require.NoError(t, err) + tok2, err := svc.Generate(testShareID) + require.NoError(t, err) + + assert.Equal(t, tok1.ShareIDHash, tok2.ShareIDHash) + assert.NotEqual(t, tok1.SecretHash(), tok2.SecretHash()) + assert.NotEqual(t, tok1.String(), tok2.String()) + + other, err := svc.Generate("9f9f9f9-9f9f-9f9f-9f9f-9f9f9f9f9f9f") + require.NoError(t, err) + assert.NotEqual(t, tok1.ShareIDHash, other.ShareIDHash) +} + +func TestParse(t *testing.T) { + svc := NewTokenService() + original, err := svc.Generate(testShareID) + require.NoError(t, err) + + tests := []struct { + name string + encoded string + wantErr bool + }{ + {name: "valid", encoded: original.String()}, + {name: "wrong version", encoded: "v2." + original.ShareIDHash + "." + original.secret, wantErr: true}, + {name: "missing version", encoded: original.ShareIDHash + "." + original.secret, wantErr: true}, + {name: "too many parts", encoded: original.String() + ".extra", wantErr: true}, + {name: "empty share hash", encoded: "v1.." + original.secret, wantErr: true}, + {name: "empty secret", encoded: "v1." + original.ShareIDHash + ".", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed, err := svc.Parse(tt.encoded) + if tt.wantErr { + assert.ErrorIs(t, err, ErrInvalidToken) + return + } + + require.NoError(t, err) + assert.Equal(t, original.ShareIDHash, parsed.ShareIDHash) + assert.Equal(t, original.SecretHash(), parsed.SecretHash()) + assert.Equal(t, original.String(), parsed.String()) + }) + } +} + +func TestVerify(t *testing.T) { + svc := NewTokenService() + tok, err := svc.Generate(testShareID) + require.NoError(t, err) + + tests := []struct { + name string + token Token + storedSecretHash string + wantErr bool + }{ + {name: "valid", token: *tok, storedSecretHash: tok.SecretHash()}, + {name: "wrong stored secret", token: *tok, storedSecretHash: Hash("other-secret"), wantErr: true}, + {name: "missing fields", token: Token{ShareIDHash: tok.ShareIDHash}, storedSecretHash: tok.SecretHash(), wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := svc.Verify(tt.token, tt.storedSecretHash) + if tt.wantErr { + assert.ErrorIs(t, err, ErrInvalidToken) + return + } + assert.NoError(t, err) + }) + } +} diff --git a/services/graph/pkg/config/config.go b/services/graph/pkg/config/config.go index a40d17a919..a1d0b16629 100644 --- a/services/graph/pkg/config/config.go +++ b/services/graph/pkg/config/config.go @@ -26,14 +26,13 @@ type Config struct { TokenManager *TokenManager `yaml:"token_manager"` GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"` - Application Application `yaml:"application"` - Spaces Spaces `yaml:"spaces"` - Identity Identity `yaml:"identity"` - IncludeOCMSharees bool `yaml:"include_ocm_sharees" env:"OC_ENABLE_OCM;GRAPH_INCLUDE_OCM_SHAREES" desc:"Include OCM sharees when listing users." introductionVersion:"1.0.0"` - EnableGuestInvites bool `yaml:"enable_guest_invites" env:"GRAPH_ENABLE_GUEST_INVITES" desc:"Enables creating permission invites (shares) to mail addresses. Disabled by default." introductionVersion:"8.1.0"` - Events Events `yaml:"events"` - UnifiedRoles UnifiedRoles `yaml:"unified_roles"` - MaxConcurrency int `yaml:"max_concurrency" env:"OC_MAX_CONCURRENCY;GRAPH_MAX_CONCURRENCY" desc:"The maximum number of concurrent requests the service will handle." introductionVersion:"1.0.0"` + Application Application `yaml:"application"` + Spaces Spaces `yaml:"spaces"` + Identity Identity `yaml:"identity"` + IncludeOCMSharees bool `yaml:"include_ocm_sharees" env:"OC_ENABLE_OCM;GRAPH_INCLUDE_OCM_SHAREES" desc:"Include OCM sharees when listing users." introductionVersion:"1.0.0"` + Events Events `yaml:"events"` + UnifiedRoles UnifiedRoles `yaml:"unified_roles"` + MaxConcurrency int `yaml:"max_concurrency" env:"OC_MAX_CONCURRENCY;GRAPH_MAX_CONCURRENCY" desc:"The maximum number of concurrent requests the service will handle." introductionVersion:"1.0.0"` Keycloak Keycloak `yaml:"keycloak"` ServiceAccount ServiceAccount `yaml:"service_account"` diff --git a/services/graph/pkg/config/defaults/defaultconfig.go b/services/graph/pkg/config/defaults/defaultconfig.go index 598e4a099e..b7ec0af936 100644 --- a/services/graph/pkg/config/defaults/defaultconfig.go +++ b/services/graph/pkg/config/defaults/defaultconfig.go @@ -75,8 +75,7 @@ func DefaultConfig() *config.Config { AssignDefaultUserRole: true, IdentitySearchMinLength: 3, }, - EnableGuestInvites: false, - Reva: shared.DefaultRevaConfig(), + Reva: shared.DefaultRevaConfig(), Spaces: config.Spaces{ StorageUsersAddress: "eu.opencloud.api.storage-users", WebDavBase: "https://localhost:9200", diff --git a/services/graph/pkg/service/v0/api_driveitem_permissions.go b/services/graph/pkg/service/v0/api_driveitem_permissions.go index 3e9914916a..e19eec5aa8 100644 --- a/services/graph/pkg/service/v0/api_driveitem_permissions.go +++ b/services/graph/pkg/service/v0/api_driveitem_permissions.go @@ -164,7 +164,7 @@ func (s DriveItemPermissionsService) Invite(ctx context.Context, resourceId *sto var expiration *types.Timestamp var cTime *types.Timestamp if email := driveRecipient.GetEmail(); email != "" { - if !s.config.EnableGuestInvites { + if s.config.Commons == nil || !s.config.Commons.EnableGuestLinks { return libregraph.Permission{}, errorcode.New(errorcode.NotSupported, "sharing with mail recipients is not enabled") } createShareRequest := createShareRequestToMail(email, statResponse.GetInfo(), cs3ResourcePermissions) diff --git a/services/graph/pkg/service/v0/api_driveitem_permissions_test.go b/services/graph/pkg/service/v0/api_driveitem_permissions_test.go index e075a73003..5c386b69f6 100644 --- a/services/graph/pkg/service/v0/api_driveitem_permissions_test.go +++ b/services/graph/pkg/service/v0/api_driveitem_permissions_test.go @@ -33,6 +33,7 @@ import ( cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks" "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/pkg/shared" "github.com/opencloud-eu/opencloud/services/graph/mocks" "github.com/opencloud-eu/opencloud/services/graph/pkg/config/defaults" "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" @@ -168,7 +169,7 @@ var _ = Describe("DriveItemPermissionsService", func() { }) It("creates guest share using an email address", func() { - cfg.EnableGuestInvites = true + cfg.Commons = &shared.Commons{EnableGuestLinks: true} gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponse, nil) gatewayClient.On("CreateShare", mock.Anything, mock.Anything).Return(createShareResponse, nil) driveItemInvite.Recipients = []libregraph.DriveRecipient{ diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go index 8b43147a2b..457e150197 100644 --- a/services/proxy/pkg/config/defaults/defaultconfig.go +++ b/services/proxy/pkg/config/defaults/defaultconfig.go @@ -283,6 +283,11 @@ func DefaultPolicies() []config.Policy { Endpoint: "/graph/v1.0/invitations", Service: "eu.opencloud.web.invitations", }, + { + Endpoint: "/graph/v1beta1/extensions/org.libregraph/guestLinks", + Service: "eu.opencloud.web.auth-guest", + Unprotected: true, + }, { Endpoint: "/graph/", Service: "eu.opencloud.web.graph",