Merge pull request #3609 from maki5/feat/guestauth_service

feat(guestlinks)!: auth-guest service
This commit is contained in:
Andre Duffeck authored and GitHub committed 2026-10-06 09:23:22 +02:00
commit 6ff4794ca8
53 files changed
+3298 -17

No files matched your search

+1 -1
View File
@@ -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":
+1
View File
@@ -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 \
+1 -1
View File
@@ -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
+6
View File
@@ -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
+16
View File
@@ -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{
+7
View File
@@ -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"`
+8
View File
@@ -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
+2
View File
@@ -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"`
+2
View File
@@ -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(),
+3 -3
View File
@@ -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)
+17
View File
@@ -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
}
+1
View File
@@ -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
+14
View File
@@ -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: {}
+15
View File
@@ -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)
+101
View File
@@ -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.
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
},
}
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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)
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
},
}
}
@@ -0,0 +1,21 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
},
}
}
+90
View File
@@ -0,0 +1,90 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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%%"`
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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%%"`
}
@@ -0,0 +1,85 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
}
@@ -0,0 +1,45 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
}
@@ -0,0 +1,9 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
}
@@ -0,0 +1,38 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
}
@@ -0,0 +1,53 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
}
}
@@ -0,0 +1,43 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
}
@@ -0,0 +1,60 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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})
}
@@ -0,0 +1,71 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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...)
}
}
@@ -0,0 +1,54 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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})
}
}
@@ -0,0 +1,144 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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)
}
@@ -0,0 +1,72 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
}
@@ -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
}
@@ -0,0 +1,41 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
}
}
@@ -0,0 +1,216 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
}
@@ -0,0 +1,256 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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)
}
@@ -0,0 +1,69 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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())
}
@@ -0,0 +1,151 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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)
}
@@ -0,0 +1,67 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
}
}
@@ -0,0 +1,146 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
}
@@ -0,0 +1,37 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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)
}
@@ -0,0 +1,44 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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)
}
@@ -0,0 +1,177 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
}
@@ -0,0 +1,170 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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())
}
@@ -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
}
@@ -0,0 +1,29 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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
}
@@ -0,0 +1,78 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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[:])
}
@@ -0,0 +1,102 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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)
})
}
}
+7 -8
View File
@@ -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"`
@@ -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",
@@ -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)
@@ -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{
@@ -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",