diff --git a/services/auth-guest/README.md b/services/auth-guest/README.md index 52c6952b1e..047eb12f5c 100644 --- a/services/auth-guest/README.md +++ b/services/auth-guest/README.md @@ -75,24 +75,23 @@ sequenceDiagram A guest session (signed JWT) is valid for 24 hours by default (`AUTH_GUEST_JWT_TTL`); a link token is single-use and valid for 30 minutes. -Renewal starts from a *failed* attempt`: +Renewal starts from a *failed* attempt: - an authenticated request made with an expired session is rejected with `401`; + the browser still holds the (expired) session cookie; - `redeem` on a link that was already used answers `409`, and on an expired - link `401`. + link `401`; the client still holds the link token. -Both errors carry the share id, which the client sends to `renew` as -`permissionId`. - -`renew` itself does not create a -session. It requires an existing token record for the share -, validates the share, generates a new link token and a 6-digit PIN (both valid -for 30 minutes), overwrites the stored token and PIN hashes (invalidating the -previous link and PIN), resets the redeemed flag so the new link can be -redeemed, and publishes `GuestTokenRenewed`. It answers `200`. This works -whether the record is unused, expired or already redeemed (the guest already -redeemed an earlier link); if publishing fails, the previous record is -restored. +`renew` requires the share id (`permissionId`) and is authorized by either the +(possibly expired) session cookie or the previous link token, whichever the +client has; the credential must belong to the given share. It does not create a +session. It requires an existing token record for the share, validates the +share, generates a new link token and a 6-digit PIN (both valid for 30 minutes), +overwrites the stored token and PIN hashes (invalidating the previous link and +PIN), resets the redeemed flag so the new link can be redeemed, and publishes +`GuestTokenRenewed`. It answers `200`. This works whether the record is unused, +expired or already redeemed (the guest already redeemed an earlier link); if +publishing fails, the previous record is restored. To turn the new credentials into a session the guest either redeems the link (`redeem`) or enters the PIN (`verify/pin`); both return a session cookie and @@ -109,7 +108,7 @@ sequenceDiagram participant Bus as Event bus Note over Web: Session expired / previous link used or expired - Web->>+Renew: POST renew { permissionId } + Web->>+Renew: POST renew { permissionId } (+ session cookie or link token) Note right of Renew: Validate share, generate token + PIN, store hashes Renew->>Bus: publish GuestTokenRenewed Note right of Bus: External consumer delivers token + PIN (out of scope) @@ -144,14 +143,16 @@ sequenceDiagram 30 minutes; the session cookie lives for 24 hours by default (`AUTH_GUEST_JWT_TTL`). 3. **Renew** — the guest posts the `permissionId` to - `POST /graph/v1beta1/extensions/org.libregraph/guestLinks/renew`. - An existing record is required; otherwise the request fails with - `tokenNotFound`. The service validates the share, generates a new link token - and a 6-digit PIN (both valid for 30 minutes), overwrites the stored token - and PIN hashes (invalidating the previous link and PIN) and resets the - redeemed flag so the new link can be redeemed, then publishes the - `GuestTokenRenewed` event. This works whether the record is unused, expired - or already redeemed. If publishing fails, the previous record is restored. + `POST /graph/v1beta1/extensions/org.libregraph/guestLinks/renew`, authorized + by either the previous link token (`{ "token": "" }`) or the (possibly + expired) session cookie, which must both belong to that share. An existing + record is required; otherwise the request fails with `tokenNotFound`. The + service validates the share, generates a new link token and a 6-digit PIN + (both valid for 30 minutes), overwrites the stored token and PIN hashes + (invalidating the previous link and PIN) and resets the redeemed flag so the + new link can be redeemed, then publishes the `GuestTokenRenewed` event. This + works whether the record is unused, expired or already redeemed. If + publishing fails, the previous record is restored. 4. **Verify PIN** — the guest posts the PIN and the `permissionId` to `POST /graph/v1beta1/extensions/org.libregraph/guestLinks/verify/pin`. The service validates the record and the share, compares the PIN against the @@ -177,12 +178,15 @@ Alias: `.../guestLinks/verify/token` #### `POST .../guestLinks/renew` -- **Request body:** `{ "permissionId": "" }` +- **Request body:** `{ "permissionId": "", "token": "" }` + (`token` optional when the session cookie is sent) +- **Authorization:** the share id is required; the body link token or the + session cookie must belong to it, the link token takes precedence - **Success:** `200` (no session cookie; the new link/PIN are delivered out of band) -- **Errors:** `400 invalidRequest`, `404 tokenNotFound`, - `404 shareNotFound`, `410 shareExpired`, `503 serviceUnavailable`, - `500 internalError` +- **Errors:** `400 invalidRequest`, `401 tokenInvalid`, `401 sessionInvalid`, + `404 tokenNotFound`, `404 shareNotFound`, `410 shareExpired`, + `409 tokenAlreadyRedeemed`, `503 serviceUnavailable`, `500 internalError` #### `POST .../guestLinks/verify/pin` diff --git a/services/auth-guest/pkg/server/http/errors.go b/services/auth-guest/pkg/server/http/errors.go index a0c0c75c84..a920d5456e 100644 --- a/services/auth-guest/pkg/server/http/errors.go +++ b/services/auth-guest/pkg/server/http/errors.go @@ -9,6 +9,7 @@ import ( "net/http" "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest" + "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" ) @@ -58,6 +59,10 @@ func writeGuestError(w http.ResponseWriter, err error) { status, errorType = http.StatusUnauthorized, "pinInvalid" case errors.Is(ge.ErrorType, authguest.ErrPinExpired): status, errorType = http.StatusUnauthorized, "pinExpired" + case errors.Is(ge.ErrorType, jwt.ErrInvalidSession): + status, errorType = http.StatusUnauthorized, "sessionInvalid" + case errors.Is(ge.ErrorType, authguest.ErrPermissionMismatch): + status, errorType = http.StatusBadRequest, "invalidRequest" } message := ge.ErrorType.Error() diff --git a/services/auth-guest/pkg/server/http/renew.go b/services/auth-guest/pkg/server/http/renew.go index 336fa21525..834790b776 100644 --- a/services/auth-guest/pkg/server/http/renew.go +++ b/services/auth-guest/pkg/server/http/renew.go @@ -8,16 +8,22 @@ import ( "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" ) -// RenewRequest is the request body for token renewal. +// RenewRequest is the request body for token renewal. The share id is required; +// the previous link token is optional when the (possibly expired) session +// cookie is sent instead. type RenewRequest struct { PermissionID string `json:"permissionId"` + Token string `json:"token"` } -// RenewHandler generates a new guest link token for the submitted share. -func RenewHandler(log log.Logger, s authguest.AuthGuest) func(w http.ResponseWriter, r *http.Request) { +// RenewHandler generates a new guest link token and PIN for the share +// identified by the permission id, authorized by the submitted link token or +// the session cookie. +func RenewHandler(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 RenewRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -32,7 +38,12 @@ func RenewHandler(log log.Logger, s authguest.AuthGuest) func(w http.ResponseWri return } - if err := s.Renew(r.Context(), req.PermissionID); err != nil { + sessionToken := "" + if cookie, err := r.Cookie(cfg.JWT.CookieName); err == nil { + sessionToken = cookie.Value + } + + if err := s.Renew(r.Context(), req.PermissionID, req.Token, sessionToken); err != nil { log.Debug().Err(err).Msg("renew failed") writeGuestError(w, err) return diff --git a/services/auth-guest/pkg/server/http/renew_test.go b/services/auth-guest/pkg/server/http/renew_test.go index 2bcf35910a..62c3edd878 100644 --- a/services/auth-guest/pkg/server/http/renew_test.go +++ b/services/auth-guest/pkg/server/http/renew_test.go @@ -10,32 +10,60 @@ import ( "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/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) +const testCookieName = "__Host-oc_guest_session" + func newRenewHandler(t *testing.T, svc authguest.AuthGuest) http.HandlerFunc { t.Helper() - return RenewHandler(log.NopLogger(), svc) + cfg := &config.Config{ + JWT: config.JWT{ + CookieName: testCookieName, + TTL: time.Hour, + }, + } + return RenewHandler(log.NopLogger(), svc, cfg) } -func TestRenewHandler(t *testing.T) { +func TestRenewHandlerWithLinkToken(t *testing.T) { svcMock := mocks.NewAuthGuest(t) - svcMock.On("Renew", mock.Anything, "share-1").Return(nil) + svcMock.On("Renew", mock.Anything, "share-1", "link-token", "").Return(nil) - body, err := json.Marshal(RenewRequest{PermissionID: "share-1"}) + body, err := json.Marshal(RenewRequest{PermissionID: "share-1", Token: "link-token"}) require.NoError(t, err) rr := httptest.NewRecorder() newRenewHandler(t, svcMock)(rr, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))) assert.Equal(t, http.StatusOK, rr.Code) - svcMock.AssertCalled(t, "Renew", mock.Anything, "share-1") + svcMock.AssertCalled(t, "Renew", mock.Anything, "share-1", "link-token", "") +} + +func TestRenewHandlerWithSessionCookie(t *testing.T) { + svcMock := mocks.NewAuthGuest(t) + svcMock.On("Renew", mock.Anything, "share-1", "", "session-token").Return(nil) + + body, err := json.Marshal(RenewRequest{PermissionID: "share-1"}) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body))) + req.AddCookie(&http.Cookie{Name: testCookieName, Value: "session-token"}) + + rr := httptest.NewRecorder() + newRenewHandler(t, svcMock)(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + svcMock.AssertCalled(t, "Renew", mock.Anything, "share-1", "", "session-token") } func TestRenewHandlerErrorMapping(t *testing.T) { @@ -60,6 +88,20 @@ func TestRenewHandlerErrorMapping(t *testing.T) { wantType: "shareExpired", wantPermission: "share-1", }, + { + name: "permission mismatch", + err: &authguest.GuestError{ErrorType: authguest.ErrPermissionMismatch, ShareID: "share-1"}, + wantStatus: http.StatusBadRequest, + wantType: "invalidRequest", + wantPermission: "share-1", + }, + { + name: "invalid session", + err: &authguest.GuestError{ErrorType: jwt.ErrInvalidSession, ShareID: "share-1"}, + wantStatus: http.StatusUnauthorized, + wantType: "sessionInvalid", + wantPermission: "share-1", + }, { name: "internal error", err: errors.New("boom"), @@ -77,9 +119,9 @@ func TestRenewHandlerErrorMapping(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svcMock := mocks.NewAuthGuest(t) - svcMock.On("Renew", mock.Anything, "share-1").Return(tt.err) + svcMock.On("Renew", mock.Anything, "share-1", "link-token", "").Return(tt.err) - body, err := json.Marshal(RenewRequest{PermissionID: "share-1"}) + body, err := json.Marshal(RenewRequest{PermissionID: "share-1", Token: "link-token"}) require.NoError(t, err) rr := httptest.NewRecorder() @@ -111,7 +153,7 @@ func TestRenewHandlerMalformedBody(t *testing.T) { func TestRenewHandlerMissingPermissionID(t *testing.T) { svcMock := mocks.NewAuthGuest(t) - body, err := json.Marshal(RenewRequest{}) + body, err := json.Marshal(RenewRequest{Token: "link-token"}) require.NoError(t, err) rr := httptest.NewRecorder() diff --git a/services/auth-guest/pkg/server/http/server.go b/services/auth-guest/pkg/server/http/server.go index f1d3831dc0..aa54cbcd59 100644 --- a/services/auth-guest/pkg/server/http/server.go +++ b/services/auth-guest/pkg/server/http/server.go @@ -61,7 +61,7 @@ func Server(opts ...Option) (ohttp.Service, error) { 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)) r.Post("/v1beta1/extensions/org.libregraph/guestLinks/verify/token", RedeemHandler(options.Logger, options.Service, options.Config)) - r.Post("/v1beta1/extensions/org.libregraph/guestLinks/renew", RenewHandler(options.Logger, options.Service)) + r.Post("/v1beta1/extensions/org.libregraph/guestLinks/renew", RenewHandler(options.Logger, options.Service, options.Config)) r.Post("/v1beta1/extensions/org.libregraph/guestLinks/verify/pin", VerifyPinHandler(options.Logger, options.Service, options.Config)) }) diff --git a/services/auth-guest/pkg/service/authguest/mocks/auth_guest.go b/services/auth-guest/pkg/service/authguest/mocks/auth_guest.go index b639ace055..0d7a1429d4 100644 --- a/services/auth-guest/pkg/service/authguest/mocks/auth_guest.go +++ b/services/auth-guest/pkg/service/authguest/mocks/auth_guest.go @@ -227,16 +227,16 @@ func (_c *AuthGuest_Redeem_Call) RunAndReturn(run func(ctx context.Context, toke } // Renew provides a mock function for the type AuthGuest -func (_mock *AuthGuest) Renew(ctx context.Context, shareID string) error { - ret := _mock.Called(ctx, shareID) +func (_mock *AuthGuest) Renew(ctx context.Context, shareID string, tokenString string, sessionToken string) error { + ret := _mock.Called(ctx, shareID, tokenString, sessionToken) if len(ret) == 0 { panic("no return value specified for Renew") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { - r0 = returnFunc(ctx, shareID) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { + r0 = returnFunc(ctx, shareID, tokenString, sessionToken) } else { r0 = ret.Error(0) } @@ -251,11 +251,13 @@ type AuthGuest_Renew_Call struct { // Renew is a helper method to define mock.On call // - ctx context.Context // - shareID string -func (_e *AuthGuest_Expecter) Renew(ctx any, shareID any) *AuthGuest_Renew_Call { - return &AuthGuest_Renew_Call{Call: _e.mock.On("Renew", ctx, shareID)} +// - tokenString string +// - sessionToken string +func (_e *AuthGuest_Expecter) Renew(ctx any, shareID any, tokenString any, sessionToken any) *AuthGuest_Renew_Call { + return &AuthGuest_Renew_Call{Call: _e.mock.On("Renew", ctx, shareID, tokenString, sessionToken)} } -func (_c *AuthGuest_Renew_Call) Run(run func(ctx context.Context, shareID string)) *AuthGuest_Renew_Call { +func (_c *AuthGuest_Renew_Call) Run(run func(ctx context.Context, shareID string, tokenString string, sessionToken string)) *AuthGuest_Renew_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -265,9 +267,19 @@ func (_c *AuthGuest_Renew_Call) Run(run func(ctx context.Context, shareID string if args[1] != nil { arg1 = args[1].(string) } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } run( arg0, arg1, + arg2, + arg3, ) }) return _c @@ -278,7 +290,7 @@ func (_c *AuthGuest_Renew_Call) Return(err error) *AuthGuest_Renew_Call { return _c } -func (_c *AuthGuest_Renew_Call) RunAndReturn(run func(ctx context.Context, shareID string) error) *AuthGuest_Renew_Call { +func (_c *AuthGuest_Renew_Call) RunAndReturn(run func(ctx context.Context, shareID string, tokenString string, sessionToken string) error) *AuthGuest_Renew_Call { _c.Call.Return(run) return _c } diff --git a/services/auth-guest/pkg/service/authguest/service.go b/services/auth-guest/pkg/service/authguest/service.go index d217f38de7..4105614717 100644 --- a/services/auth-guest/pkg/service/authguest/service.go +++ b/services/auth-guest/pkg/service/authguest/service.go @@ -31,6 +31,7 @@ var ErrShareExpired = errors.New("share expired") var ErrEventsNotConfigured = errors.New("event publisher not configured") var ErrPinInvalid = errors.New("pin invalid") var ErrPinExpired = errors.New("pin expired") +var ErrPermissionMismatch = errors.New("permissionId does not match the provided credentials") const guestLinkTokenTTL = 30 * time.Minute const guestPinTTL = 30 * time.Minute @@ -55,7 +56,7 @@ type SessionResponse struct { type AuthGuest interface { CreateToken(ctx context.Context, shareID string) (*token.Token, error) Redeem(ctx context.Context, tokenString string) (*SessionResponse, error) - Renew(ctx context.Context, shareID string) error + Renew(ctx context.Context, shareID, tokenString, sessionToken string) error VerifyPin(ctx context.Context, shareID, pinValue string) (*SessionResponse, error) CleanupShare(shareID string) error } @@ -119,13 +120,16 @@ func (s *AuthGuestService) Redeem(ctx context.Context, tokenString string) (*Ses return nil, err } - if err := s.store.Update(rec.ShareIDHash, func(r *storage.Record) error { + if _, err := s.store.UpdateFrom(*rec, func(r *storage.Record) error { if r.Redeemed { return ErrAlreadyRedeemed } r.Redeemed = true return nil }); err != nil { + if errors.Is(err, storage.ErrConflict) { + err = ErrAlreadyRedeemed + } return nil, &GuestError{ErrorType: err, ShareID: rec.ShareID} } @@ -137,12 +141,17 @@ func (s *AuthGuestService) Redeem(ctx context.Context, tokenString string) (*Ses return &SessionResponse{SessionToken: sessionToken, ShareID: rec.ShareID}, nil } -// Renew generates a new guest link token and PIN for the given share. -func (s *AuthGuestService) Renew(ctx context.Context, shareID string) error { +// Renew generates a new guest link token and PIN for shareID, authorized by the +// previous link token or a (possibly expired) session token. +func (s *AuthGuestService) Renew(ctx context.Context, shareID, tokenString, sessionToken string) error { if s.publisher == nil { return ErrEventsNotConfigured } + if err := s.verifyRenewCredentials(shareID, tokenString, sessionToken); err != nil { + return err + } + share, err := s.validateShare(ctx, shareID) if err != nil { return err @@ -165,16 +174,23 @@ func (s *AuthGuestService) Renew(ctx context.Context, shareID string) error { now := time.Now() - var previous storage.Record - if err := s.store.Update(token.Hash(shareID), func(rec *storage.Record) error { - previous = *rec + current, err := s.store.Get(token.Hash(shareID)) + if err != nil { + return &GuestError{ErrorType: err, ShareID: shareID} + } + + updated, err := s.store.UpdateFrom(*current, func(rec *storage.Record) error { rec.SecretHash = tok.SecretHash() rec.PinHash = pinHash rec.Expiry = now.Add(guestLinkTokenTTL) rec.PinExpiry = now.Add(guestPinTTL) rec.Redeemed = false return nil - }); err != nil { + }) + if err != nil { + if errors.Is(err, storage.ErrConflict) { + err = ErrAlreadyRedeemed + } return &GuestError{ErrorType: err, ShareID: shareID} } @@ -187,8 +203,8 @@ func (s *AuthGuestService) Renew(ctx context.Context, shareID string) error { Pin: pinStr, Timestamp: now, }); err != nil { - if rbErr := s.store.Update(token.Hash(shareID), func(rec *storage.Record) error { - *rec = previous + if _, rbErr := s.store.UpdateFrom(*updated, func(rec *storage.Record) error { + *rec = *current return nil }); rbErr != nil { return fmt.Errorf("publishing guest token renewed event failed: %w (rollback failed: %v)", err, rbErr) @@ -252,6 +268,53 @@ func (s *AuthGuestService) CleanupShare(shareID string) error { return nil } +// verifyRenewCredentials checks that the previous link token or a (possibly +// expired) session token belongs to shareID. +func (s *AuthGuestService) verifyRenewCredentials(shareID, tokenString, sessionToken string) error { + switch { + case tokenString != "": + return s.verifyRenewToken(shareID, tokenString) + case sessionToken != "": + if s.jwtService == nil { + return &GuestError{ErrorType: jwt.ErrInvalidSession, ShareID: shareID} + } + sessionShareID, err := s.jwtService.Verify(sessionToken) + if err != nil { + return &GuestError{ErrorType: err, ShareID: shareID} + } + if sessionShareID != shareID { + return &GuestError{ErrorType: ErrPermissionMismatch, ShareID: shareID} + } + return nil + default: + return &GuestError{ErrorType: jwt.ErrInvalidSession, ShareID: shareID} + } +} + +// verifyRenewToken checks that the link token belongs to shareID and matches +// the stored record, ignoring its expiry and redeemed state. +func (s *AuthGuestService) verifyRenewToken(shareID, tokenString string) error { + tok, err := s.tokenSvc.Parse(tokenString) + if err != nil { + return &GuestError{ErrorType: err, ShareID: shareID} + } + + if tok.ShareIDHash != token.Hash(shareID) { + return &GuestError{ErrorType: ErrPermissionMismatch, ShareID: shareID} + } + + rec, err := s.store.Get(tok.ShareIDHash) + if err != nil { + return &GuestError{ErrorType: err, ShareID: shareID} + } + + if err := s.tokenSvc.Verify(*tok, rec.SecretHash); err != nil { + return &GuestError{ErrorType: err, ShareID: shareID} + } + + return nil +} + // VerifyToken validates a token and returns its stored record. // // Until the secret has been verified, all failures are reported as @@ -280,7 +343,7 @@ func (s *AuthGuestService) verifyToken(tokenString string) (*storage.Record, err return nil, &GuestError{ErrorType: ErrAlreadyRedeemed, ShareID: rec.ShareID} } - return &rec, nil + return rec, nil } // validateShare extracts the share information from the gateway and checks its existence and expiration. diff --git a/services/auth-guest/pkg/service/authguest/service_test.go b/services/auth-guest/pkg/service/authguest/service_test.go index 049741673e..6d9777b4b8 100644 --- a/services/auth-guest/pkg/service/authguest/service_test.go +++ b/services/auth-guest/pkg/service/authguest/service_test.go @@ -46,6 +46,41 @@ func (p *testPublisher) Publish(_ string, ev any, _ ...microevents.PublishOption return p.err } +// rewriteStore wraps a Manager and runs onGet once after the first Get, which +// lets tests simulate a concurrent modification between a read and a later +// write. +type rewriteStore struct { + storage.Manager + onGet func() +} + +func (s *rewriteStore) Get(shareIDHash string) (*storage.Record, error) { + rec, err := s.Manager.Get(shareIDHash) + if s.onGet != nil { + onGet := s.onGet + s.onGet = nil + onGet() + } + return rec, err +} + +// mutatingPublisher wraps testPublisher and runs onPublish once before +// publishing, which lets tests simulate a concurrent modification during a +// failed publish. +type mutatingPublisher struct { + testPublisher + onPublish func() +} + +func (p *mutatingPublisher) Publish(name string, ev any, opts ...microevents.PublishOption) error { + if p.onPublish != nil { + onPublish := p.onPublish + p.onPublish = nil + onPublish() + } + return p.testPublisher.Publish(name, ev, opts...) +} + type gatewayTestSelector struct { client gateway.GatewayAPIClient } @@ -99,6 +134,22 @@ func newToken(t *testing.T) (string, storage.Record) { return tok.String(), rec } +func newTokenFor(t *testing.T, shareID string) (string, storage.Record) { + t.Helper() + ts := token.NewTokenService() + tok, err := ts.Generate(shareID) + require.NoError(t, err) + + rec := storage.Record{ + ShareID: shareID, + 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( @@ -177,7 +228,7 @@ func TestVerifyToken(t *testing.T) { if tt.redeemed { rec.Redeemed = true } - store.On("Get", rec.ShareIDHash).Return(rec, nil) + store.On("Get", rec.ShareIDHash).Return(&rec, nil) got, err := s.verifyToken(tok) if tt.wantErr != nil { @@ -291,6 +342,34 @@ func TestRedeemAlreadyRedeemed(t *testing.T) { assert.ErrorIs(t, ge.ErrorType, ErrAlreadyRedeemed) } +func TestRedeemConflictIsAlreadyRedeemed(t *testing.T) { + base := storage.NewFileManager(t.TempDir()) + tok, rec := newToken(t) + require.NoError(t, base.Add(rec)) + + store := &rewriteStore{Manager: base} + store.onGet = func() { + require.NoError(t, base.Update(rec.ShareIDHash, func(r *storage.Record) error { + 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, + })) + + _, err := s.Redeem(context.Background(), tok) + var ge *GuestError + require.ErrorAs(t, err, &ge) + assert.ErrorIs(t, ge.ErrorType, ErrAlreadyRedeemed) + + got, err := base.Get(rec.ShareIDHash) + require.NoError(t, err) + assert.False(t, got.Redeemed) +} + func newRenewService(t *testing.T, store storage.Manager, gwc *cs3mocks.GatewayAPIClient, publisher revaevents.Publisher) *AuthGuestService { t.Helper() return NewAuthGuestService( @@ -298,6 +377,7 @@ func newRenewService(t *testing.T, store storage.Manager, gwc *cs3mocks.GatewayA store, GatewaySelector(newGatewayTestSelector(gwc)), ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}), + JWT(jwt.NewJwtService("test-secret", 24*time.Hour)), EventsPublisher(publisher), ) } @@ -311,7 +391,8 @@ func existingRecord() storage.Record { func TestRenewPersistsRecordAndPublishesEvent(t *testing.T) { store := storage.NewFileManager(t.TempDir()) - require.NoError(t, store.Add(existingRecord())) + tok, rec := newToken(t) + require.NoError(t, store.Add(rec)) publisher := &testPublisher{} gwc := newGatewayMock(&collaboration.GetShareResponse{ @@ -321,10 +402,11 @@ func TestRenewPersistsRecordAndPublishesEvent(t *testing.T) { s := newRenewService(t, store, gwc, publisher) - require.NoError(t, s.Renew(context.Background(), testShareID)) + require.NoError(t, s.Renew(context.Background(), testShareID, tok, "")) replaced, err := store.Get(token.Hash(testShareID)) require.NoError(t, err) + assert.Equal(t, rec.Revision+1, replaced.Revision) assert.Equal(t, testShareID, replaced.ShareID) assert.Equal(t, token.Hash(testShareID), replaced.ShareIDHash) assert.NotEmpty(t, replaced.SecretHash) @@ -351,8 +433,82 @@ func TestRenewPersistsRecordAndPublishesEvent(t *testing.T) { assert.True(t, pinOK) } +func TestRenewWithExpiredSessionToken(t *testing.T) { + store := storage.NewFileManager(t.TempDir()) + require.NoError(t, store.Add(existingRecord())) + + jwtSvc := jwt.NewJwtService("test-secret", -time.Hour) + sessionToken, err := jwtSvc.Sign(testShareID) + require.NoError(t, err) + + publisher := &testPublisher{} + s := NewAuthGuestService( + token.NewTokenService(), + store, + GatewaySelector(newGatewayTestSelector(newGatewayMock(&collaboration.GetShareResponse{ + Status: &rpc.Status{Code: rpc.Code_CODE_OK}, + Share: newGuestShare(time.Now().Add(time.Hour)), + }))), + ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}), + JWT(jwtSvc), + EventsPublisher(publisher), + ) + + require.NoError(t, s.Renew(context.Background(), testShareID, "", sessionToken)) + require.Len(t, publisher.published, 1) +} + +func TestRenewLinkTokenIgnoresRedeemedAndExpiry(t *testing.T) { + store := storage.NewFileManager(t.TempDir()) + tok, rec := newToken(t) + rec.Redeemed = true + rec.Expiry = time.Now().Add(-time.Hour) + require.NoError(t, store.Add(rec)) + + s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{ + Status: &rpc.Status{Code: rpc.Code_CODE_OK}, + Share: newGuestShare(time.Now().Add(time.Hour)), + }), &testPublisher{}) + + require.NoError(t, s.Renew(context.Background(), testShareID, tok, "")) +} + +func TestRenewInvalidCredentials(t *testing.T) { + store := storage.NewFileManager(t.TempDir()) + s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{}), &testPublisher{}) + + otherSession, err := jwt.NewJwtService("test-secret", 24*time.Hour).Sign("other-share") + require.NoError(t, err) + otherToken, _ := newTokenFor(t, "other-share") + + tests := []struct { + name string + shareID string + token string + sessionToken string + wantErr error + }{ + {name: "no credentials", shareID: testShareID, wantErr: jwt.ErrInvalidSession}, + {name: "invalid session", shareID: testShareID, sessionToken: "garbage", wantErr: jwt.ErrInvalidSession}, + {name: "invalid link token prefers token", shareID: testShareID, token: "garbage", sessionToken: "garbage", wantErr: token.ErrInvalidToken}, + {name: "session for another share", shareID: testShareID, sessionToken: otherSession, wantErr: ErrPermissionMismatch}, + {name: "token for another share", shareID: testShareID, token: otherToken, wantErr: ErrPermissionMismatch}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := s.Renew(context.Background(), tt.shareID, tt.token, tt.sessionToken) + + var ge *GuestError + require.ErrorAs(t, err, &ge) + assert.ErrorIs(t, ge.ErrorType, tt.wantErr) + }) + } +} + func TestRenewRecordNotFound(t *testing.T) { store := storage.NewFileManager(t.TempDir()) + tok, _ := newToken(t) publisher := &testPublisher{} s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{ @@ -360,7 +516,7 @@ func TestRenewRecordNotFound(t *testing.T) { Share: newGuestShare(time.Now().Add(time.Hour)), }), publisher) - err := s.Renew(context.Background(), testShareID) + err := s.Renew(context.Background(), testShareID, tok, "") require.Error(t, err) var ge *GuestError @@ -377,7 +533,7 @@ func TestRenewWithoutPublisher(t *testing.T) { s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{}), nil) - err := s.Renew(context.Background(), testShareID) + err := s.Renew(context.Background(), testShareID, "irrelevant", "") require.ErrorIs(t, err, ErrEventsNotConfigured) got, err := store.Get(token.Hash(testShareID)) @@ -388,7 +544,8 @@ func TestRenewWithoutPublisher(t *testing.T) { func TestRenewRollsBackOnPublishFailure(t *testing.T) { store := storage.NewFileManager(t.TempDir()) - require.NoError(t, store.Add(existingRecord())) + tok, rec := newToken(t) + require.NoError(t, store.Add(rec)) publisher := &testPublisher{err: errors.New("publish failed")} gwc := newGatewayMock(&collaboration.GetShareResponse{ @@ -398,27 +555,56 @@ func TestRenewRollsBackOnPublishFailure(t *testing.T) { s := newRenewService(t, store, gwc, publisher) - err := s.Renew(context.Background(), testShareID) + err := s.Renew(context.Background(), testShareID, tok, "") require.Error(t, err) assert.Contains(t, err.Error(), "publish failed") got, err := store.Get(token.Hash(testShareID)) require.NoError(t, err) - assert.Empty(t, got.SecretHash) + assert.Equal(t, rec.SecretHash, got.SecretHash) assert.Empty(t, got.PinHash) assert.True(t, got.PinExpiry.IsZero()) assert.False(t, got.Redeemed) } +func TestRenewRollbackConflictKeepsConcurrentChange(t *testing.T) { + store := storage.NewFileManager(t.TempDir()) + tok, rec := newToken(t) + require.NoError(t, store.Add(rec)) + + publisher := &mutatingPublisher{testPublisher: testPublisher{err: errors.New("publish failed")}} + publisher.onPublish = func() { + require.NoError(t, store.Update(rec.ShareIDHash, func(r *storage.Record) error { + r.SecretHash = "concurrent" + return nil + })) + } + + s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{ + Status: &rpc.Status{Code: rpc.Code_CODE_OK}, + Share: newGuestShare(time.Now().Add(time.Hour)), + }), publisher) + + err := s.Renew(context.Background(), testShareID, tok, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "publish failed") + assert.Contains(t, err.Error(), "rollback failed") + + got, err := store.Get(rec.ShareIDHash) + require.NoError(t, err) + assert.Equal(t, "concurrent", got.SecretHash) +} + func TestRenewShareNotFound(t *testing.T) { store := storage.NewFileManager(t.TempDir()) - require.NoError(t, store.Add(existingRecord())) + tok, rec := newToken(t) + require.NoError(t, store.Add(rec)) s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{ Status: &rpc.Status{Code: rpc.Code_CODE_NOT_FOUND}, }), &testPublisher{}) - err := s.Renew(context.Background(), testShareID) + err := s.Renew(context.Background(), testShareID, tok, "") require.Error(t, err) var ge *GuestError @@ -429,14 +615,15 @@ func TestRenewShareNotFound(t *testing.T) { func TestRenewShareExpired(t *testing.T) { store := storage.NewFileManager(t.TempDir()) - require.NoError(t, store.Add(existingRecord())) + tok, rec := newToken(t) + require.NoError(t, store.Add(rec)) s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{ Status: &rpc.Status{Code: rpc.Code_CODE_OK}, Share: newGuestShare(time.Now().Add(-time.Hour)), }), &testPublisher{}) - err := s.Renew(context.Background(), testShareID) + err := s.Renew(context.Background(), testShareID, tok, "") require.Error(t, err) var ge *GuestError diff --git a/services/auth-guest/pkg/service/jwt/jwt.go b/services/auth-guest/pkg/service/jwt/jwt.go index 1c6dc0c45d..49672c7796 100644 --- a/services/auth-guest/pkg/service/jwt/jwt.go +++ b/services/auth-guest/pkg/service/jwt/jwt.go @@ -4,11 +4,14 @@ package jwt import ( + "errors" "time" "github.com/golang-jwt/jwt/v5" ) +var ErrInvalidSession = errors.New("invalid session") + type jwtClaims struct { ShareID string `json:"permissionId"` jwt.RegisteredClaims @@ -35,3 +38,17 @@ func (m *JwtService) Sign(shareID string) (string, error) { } return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret) } + +// Verify returns the share id of a session token, ignoring its expiry so an +// expired session can still be used to renew the guest link. +func (m *JwtService) Verify(tokenString string) (string, error) { + claims := &jwtClaims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(*jwt.Token) (any, error) { + return m.secret, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithoutClaimsValidation()) + if err != nil || !token.Valid || claims.ShareID == "" { + return "", ErrInvalidSession + } + + return claims.ShareID, nil +} diff --git a/services/auth-guest/pkg/service/jwt/jwt_test.go b/services/auth-guest/pkg/service/jwt/jwt_test.go index 2ec12302c2..9f545bedd2 100644 --- a/services/auth-guest/pkg/service/jwt/jwt_test.go +++ b/services/auth-guest/pkg/service/jwt/jwt_test.go @@ -42,3 +42,52 @@ func TestParseExpired(t *testing.T) { _, err = parseClaims(t, m, tok) assert.Error(t, err) } + +func TestVerify(t *testing.T) { + m := NewJwtService("test-secret", time.Hour) + + tok, err := m.Sign("share-id") + require.NoError(t, err) + + shareID, err := m.Verify(tok) + require.NoError(t, err) + assert.Equal(t, "share-id", shareID) +} + +func TestVerifyExpired(t *testing.T) { + m := NewJwtService("test-secret", -time.Minute) + + tok, err := m.Sign("share-id") + require.NoError(t, err) + + shareID, err := m.Verify(tok) + require.NoError(t, err) + assert.Equal(t, "share-id", shareID) +} + +func TestVerifyInvalid(t *testing.T) { + m := NewJwtService("test-secret", time.Hour) + + tok, err := m.Sign("share-id") + require.NoError(t, err) + + other, err := NewJwtService("other-secret", time.Hour).Sign("share-id") + require.NoError(t, err) + + tests := []struct { + name string + token string + }{ + {name: "garbage", token: "not-a-token"}, + {name: "empty", token: ""}, + {name: "wrong secret", token: other}, + {name: "tampered", token: tok + "x"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := m.Verify(tt.token) + assert.ErrorIs(t, err, ErrInvalidSession) + }) + } +} diff --git a/services/auth-guest/pkg/service/pin/pin.go b/services/auth-guest/pkg/service/pin/pin.go index f73186bf06..536a727284 100644 --- a/services/auth-guest/pkg/service/pin/pin.go +++ b/services/auth-guest/pkg/service/pin/pin.go @@ -28,7 +28,7 @@ func Generate() (string, error) { } func Hash(p string) (string, error) { - if p == "" { + if len(p) != Length { return "", ErrInvalidPin } diff --git a/services/auth-guest/pkg/service/pin/pin_test.go b/services/auth-guest/pkg/service/pin/pin_test.go index ae2a48d1c9..59d4a35a33 100644 --- a/services/auth-guest/pkg/service/pin/pin_test.go +++ b/services/auth-guest/pkg/service/pin/pin_test.go @@ -41,9 +41,11 @@ func TestHashProducesArgon2id(t *testing.T) { assert.NotEqual(t, "123456", h) } -func TestHashEmptyPin(t *testing.T) { - _, err := Hash("") - assert.ErrorIs(t, err, ErrInvalidPin) +func TestHashInvalidLength(t *testing.T) { + for _, p := range []string{"", "12345", "1234567"} { + _, err := Hash(p) + assert.ErrorIs(t, err, ErrInvalidPin) + } } func TestVerify(t *testing.T) { diff --git a/services/auth-guest/pkg/service/storage/file_manager.go b/services/auth-guest/pkg/service/storage/file_manager.go index 0222993662..b917fcbb73 100644 --- a/services/auth-guest/pkg/service/storage/file_manager.go +++ b/services/auth-guest/pkg/service/storage/file_manager.go @@ -54,7 +54,7 @@ func (s *FileManager) Add(rec Record) error { } // Get returns the record for the given share id hash. -func (s *FileManager) Get(shareIDHash string) (Record, error) { +func (s *FileManager) Get(shareIDHash string) (*Record, error) { return s.get(shareIDHash) } @@ -83,8 +83,7 @@ func (s *FileManager) Remove(shareIDHash string) error { return nil } -// Update applies fn to the record under the record lock and writes it back. -// If fn returns an error the record is left unchanged. +// Update applies fn to the record under lock and writes it back. func (s *FileManager) Update(shareIDHash string, fn func(*Record) error) error { lock, err := s.lockRecord(shareIDHash) if err != nil { @@ -97,11 +96,45 @@ func (s *FileManager) Update(shareIDHash string, fn func(*Record) error) error { return err } - if err := fn(&rec); err != nil { + if err := fn(rec); err != nil { return err } - return s.upsert(rec) + rec.Revision++ + + return s.upsert(*rec) +} + +// UpdateFrom applies fn to the record under lock and writes it back, unless it +// changed since seen (its revision advanced), in which case it returns +// ErrConflict. It returns the updated record. +func (s *FileManager) UpdateFrom(seen Record, fn func(*Record) error) (*Record, error) { + lock, err := s.lockRecord(seen.ShareIDHash) + if err != nil { + return nil, err + } + defer func() { _ = lock.Unlock() }() + + rec, err := s.get(seen.ShareIDHash) + if err != nil { + return nil, err + } + + if seen.Revision < rec.Revision { + return nil, ErrConflict + } + + if err := fn(rec); err != nil { + return nil, err + } + + rec.Revision = seen.Revision + 1 + + if err := s.upsert(*rec); err != nil { + return nil, err + } + + return rec, nil } func (s *FileManager) lockRecord(shareIDHash string) (*flock.Flock, error) { @@ -141,23 +174,23 @@ func (s *FileManager) upsert(rec Record) error { return renameio.WriteFile(p, data, filePerm) } -func (s *FileManager) get(shareIDHash string) (Record, error) { +func (s *FileManager) get(shareIDHash string) (*Record, error) { p, err := s.path(shareIDHash) if err != nil { - return Record{}, err + return nil, err } data, err := os.ReadFile(p) if err != nil { if errors.Is(err, fs.ErrNotExist) { - return Record{}, ErrNotFound + return nil, ErrNotFound } - return Record{}, err + return nil, err } - rec := Record{} - if err := json.Unmarshal(data, &rec); err != nil { - return Record{}, err + rec := &Record{} + if err := json.Unmarshal(data, rec); err != nil { + return nil, err } return rec, nil diff --git a/services/auth-guest/pkg/service/storage/file_manager_test.go b/services/auth-guest/pkg/service/storage/file_manager_test.go index ef7c25438f..6a5884a2c1 100644 --- a/services/auth-guest/pkg/service/storage/file_manager_test.go +++ b/services/auth-guest/pkg/service/storage/file_manager_test.go @@ -37,7 +37,7 @@ func TestFileManagerAddGet(t *testing.T) { got, err := s.Get(rec.ShareIDHash) require.NoError(t, err) - assert.Equal(t, rec, got) + assert.Equal(t, rec, *got) } func TestFileManagerGetMissing(t *testing.T) { @@ -72,6 +72,8 @@ func TestFileManagerInvalidHash(t *testing.T) { require.ErrorIs(t, s.Remove("ab"), ErrInvalidHash) require.ErrorIs(t, s.Add(Record{ShareIDHash: "ab"}), ErrInvalidHash) require.ErrorIs(t, s.Update("ab", func(*Record) error { return nil }), ErrInvalidHash) + _, err = s.UpdateFrom(Record{ShareIDHash: "ab"}, func(*Record) error { return nil }) + require.ErrorIs(t, err, ErrInvalidHash) } func TestFileManagerRemove(t *testing.T) { @@ -112,6 +114,7 @@ func TestFileManagerUpdate(t *testing.T) { require.NoError(t, err) assert.Equal(t, "pinhash", got.PinHash) assert.True(t, got.Redeemed) + assert.Equal(t, uint64(1), got.Revision) } func TestFileManagerUpdateErrorLeavesRecordUnchanged(t *testing.T) { @@ -130,7 +133,7 @@ func TestFileManagerUpdateErrorLeavesRecordUnchanged(t *testing.T) { got, err := s.Get(rec.ShareIDHash) require.NoError(t, err) - assert.Equal(t, rec, got) + assert.Equal(t, rec, *got) } func TestFileManagerUpdateMissing(t *testing.T) { @@ -141,6 +144,65 @@ func TestFileManagerUpdateMissing(t *testing.T) { assert.ErrorIs(t, err, ErrNotFound) } +func TestFileManagerUpdateFrom(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + rec := newRecord("e0123456-7890-abcd-ef01-234567890abc") + require.NoError(t, s.Add(rec)) + + seen, err := s.Get(rec.ShareIDHash) + require.NoError(t, err) + + updated, err := s.UpdateFrom(*seen, func(r *Record) error { + r.PinHash = "pinhash" + return nil + }) + require.NoError(t, err) + require.NotNil(t, updated) + assert.Equal(t, seen.Revision+1, updated.Revision) + assert.Equal(t, "pinhash", updated.PinHash) + + got, err := s.Get(rec.ShareIDHash) + require.NoError(t, err) + assert.Equal(t, *updated, *got) +} + +func TestFileManagerUpdateFromConflict(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + rec := newRecord("e0123456-7890-abcd-ef01-234567890abc") + require.NoError(t, s.Add(rec)) + + seen, err := s.Get(rec.ShareIDHash) + require.NoError(t, err) + + require.NoError(t, s.Update(rec.ShareIDHash, func(r *Record) error { + r.SecretHash = "other" + return nil + })) + + _, err = s.UpdateFrom(*seen, func(r *Record) error { + r.PinHash = "pinhash" + return nil + }) + assert.ErrorIs(t, err, ErrConflict) + + got, err := s.Get(rec.ShareIDHash) + require.NoError(t, err) + assert.Equal(t, "other", got.SecretHash) + assert.Empty(t, got.PinHash) +} + +func TestFileManagerUpdateFromMissing(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + _, err := s.UpdateFrom(Record{ShareIDHash: "doesnotexist"}, func(*Record) error { return nil }) + assert.ErrorIs(t, err, ErrNotFound) +} + func TestFileManagerAddConcurrent(t *testing.T) { dir := t.TempDir() s := NewFileManager(dir) diff --git a/services/auth-guest/pkg/service/storage/mocks/manager.go b/services/auth-guest/pkg/service/storage/mocks/manager.go index 8336d3f9e4..d932999300 100644 --- a/services/auth-guest/pkg/service/storage/mocks/manager.go +++ b/services/auth-guest/pkg/service/storage/mocks/manager.go @@ -88,22 +88,24 @@ func (_c *Manager_Add_Call) RunAndReturn(run func(rec storage.Record) error) *Ma } // Get provides a mock function for the type Manager -func (_mock *Manager) Get(shareIDHash string) (storage.Record, error) { +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 r0 *storage.Record var r1 error - if returnFunc, ok := ret.Get(0).(func(string) (storage.Record, error)); ok { + 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 { + if returnFunc, ok := ret.Get(0).(func(string) *storage.Record); ok { r0 = returnFunc(shareIDHash) } else { - r0 = ret.Get(0).(storage.Record) + if ret.Get(0) != nil { + r0 = ret.Get(0).(*storage.Record) + } } if returnFunc, ok := ret.Get(1).(func(string) error); ok { r1 = returnFunc(shareIDHash) @@ -137,12 +139,12 @@ func (_c *Manager_Get_Call) Run(run func(shareIDHash string)) *Manager_Get_Call return _c } -func (_c *Manager_Get_Call) Return(record storage.Record, err error) *Manager_Get_Call { +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 { +func (_c *Manager_Get_Call) RunAndReturn(run func(shareIDHash string) (*storage.Record, error)) *Manager_Get_Call { _c.Call.Return(run) return _c } @@ -254,3 +256,71 @@ func (_c *Manager_Update_Call) RunAndReturn(run func(shareIDHash string, fn func _c.Call.Return(run) return _c } + +// UpdateFrom provides a mock function for the type Manager +func (_mock *Manager) UpdateFrom(seen storage.Record, fn func(*storage.Record) error) (*storage.Record, error) { + ret := _mock.Called(seen, fn) + + if len(ret) == 0 { + panic("no return value specified for UpdateFrom") + } + + var r0 *storage.Record + var r1 error + if returnFunc, ok := ret.Get(0).(func(storage.Record, func(*storage.Record) error) (*storage.Record, error)); ok { + return returnFunc(seen, fn) + } + if returnFunc, ok := ret.Get(0).(func(storage.Record, func(*storage.Record) error) *storage.Record); ok { + r0 = returnFunc(seen, fn) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*storage.Record) + } + } + if returnFunc, ok := ret.Get(1).(func(storage.Record, func(*storage.Record) error) error); ok { + r1 = returnFunc(seen, fn) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Manager_UpdateFrom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFrom' +type Manager_UpdateFrom_Call struct { + *mock.Call +} + +// UpdateFrom is a helper method to define mock.On call +// - seen storage.Record +// - fn func(*storage.Record) error +func (_e *Manager_Expecter) UpdateFrom(seen any, fn any) *Manager_UpdateFrom_Call { + return &Manager_UpdateFrom_Call{Call: _e.mock.On("UpdateFrom", seen, fn)} +} + +func (_c *Manager_UpdateFrom_Call) Run(run func(seen storage.Record, fn func(*storage.Record) error)) *Manager_UpdateFrom_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 storage.Record + if args[0] != nil { + arg0 = args[0].(storage.Record) + } + var arg1 func(*storage.Record) error + if args[1] != nil { + arg1 = args[1].(func(*storage.Record) error) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *Manager_UpdateFrom_Call) Return(record *storage.Record, err error) *Manager_UpdateFrom_Call { + _c.Call.Return(record, err) + return _c +} + +func (_c *Manager_UpdateFrom_Call) RunAndReturn(run func(seen storage.Record, fn func(*storage.Record) error) (*storage.Record, error)) *Manager_UpdateFrom_Call { + _c.Call.Return(run) + return _c +} diff --git a/services/auth-guest/pkg/service/storage/storage.go b/services/auth-guest/pkg/service/storage/storage.go index 00bb56ce1b..1392bb8e4b 100644 --- a/services/auth-guest/pkg/service/storage/storage.go +++ b/services/auth-guest/pkg/service/storage/storage.go @@ -10,6 +10,7 @@ import ( var ErrNotFound = errors.New("record not found") var ErrInvalidHash = errors.New("invalid share id hash") +var ErrConflict = errors.New("record was modified concurrently") // Record holds the data persisted for a guest share token. type Record struct { @@ -20,11 +21,13 @@ type Record struct { Expiry time.Time `json:"expiry,omitzero"` PinExpiry time.Time `json:"pinexpiry,omitzero"` Redeemed bool `json:"redeemed"` + Revision uint64 `json:"revision"` } type Manager interface { Add(rec Record) error - Get(shareIDHash string) (Record, error) + Get(shareIDHash string) (*Record, error) Remove(shareIDHash string) error Update(shareIDHash string, fn func(*Record) error) error + UpdateFrom(seen Record, fn func(*Record) error) (*Record, error) }