From a230d7e193f6b38b09e79d8dacc44c48711976eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Duffeck?= Date: Thu, 15 Oct 2026 09:15:41 +0200 Subject: [PATCH] Return ExpiresAt when issueing a session token --- services/auth-guest/README.md | 6 ++++-- services/auth-guest/pkg/server/http/verify_pin.go | 2 +- .../auth-guest/pkg/server/http/verify_pin_test.go | 4 +++- .../auth-guest/pkg/server/http/verify_token.go | 6 ++++-- .../pkg/server/http/verify_token_test.go | 4 +++- .../auth-guest/pkg/service/authguest/service.go | 13 +++++++------ .../pkg/service/authguest/service_test.go | 6 ++++-- services/auth-guest/pkg/service/jwt/jwt.go | 10 +++++++--- services/auth-guest/pkg/service/jwt/jwt_test.go | 14 ++++++++------ 9 files changed, 41 insertions(+), 24 deletions(-) diff --git a/services/auth-guest/README.md b/services/auth-guest/README.md index fa33324b7c..220acfb122 100644 --- a/services/auth-guest/README.md +++ b/services/auth-guest/README.md @@ -45,7 +45,7 @@ sequenceDiagram Redeem->>+Reva: Get Share Reva->>-Redeem: Share Note right of Redeem: Validate Share, Mark Token used - Redeem->>-Web: Set Cookie, return shareid + Redeem->>-Web: Set Cookie, return shareid and session expiry Note right of Web: HTTP only cookie with signed JWT (24h by default) Web->>+Proxy: "/graph/me/drives/sharedWithMe" Proxy->>+Reva: validate token extracted from JWT @@ -75,7 +75,9 @@ 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: +Verifying a token or a PIN returns the session expiry as `expirationDateTime`, +so the client can warn the guest before the session runs out, since it cannot +read the HttpOnly cookie. 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; diff --git a/services/auth-guest/pkg/server/http/verify_pin.go b/services/auth-guest/pkg/server/http/verify_pin.go index 6b469416c7..e22c0e1e6c 100644 --- a/services/auth-guest/pkg/server/http/verify_pin.go +++ b/services/auth-guest/pkg/server/http/verify_pin.go @@ -46,6 +46,6 @@ func VerifyPinHandler(log log.Logger, s authguest.AuthGuest, cfg *config.Config) setSessionCookie(w, cfg, result.SessionToken) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(verifyPinResponse{PermissionID: result.ShareID}) + _ = json.NewEncoder(w).Encode(verifyPinResponse{PermissionID: result.ShareID, ExpirationDateTime: result.ExpiresAt.UTC()}) } } diff --git a/services/auth-guest/pkg/server/http/verify_pin_test.go b/services/auth-guest/pkg/server/http/verify_pin_test.go index ebdb8c6658..fd1d69220d 100644 --- a/services/auth-guest/pkg/server/http/verify_pin_test.go +++ b/services/auth-guest/pkg/server/http/verify_pin_test.go @@ -34,7 +34,8 @@ func newVerifyPinHandler(t *testing.T, svc authguest.AuthGuest) http.HandlerFunc func TestVerifyPinHandler(t *testing.T) { svcMock := mocks.NewAuthGuest(t) - svcMock.On("VerifyPin", mock.Anything, "share-1", "123456").Return(&authguest.SessionResponse{SessionToken: "session-token", ShareID: "share-1"}, nil) + expiresAt := time.Date(2026, 10, 15, 10, 30, 0, 0, time.FixedZone("CEST", 2*60*60)) + svcMock.On("VerifyPin", mock.Anything, "share-1", "123456").Return(&authguest.SessionResponse{SessionToken: "session-token", ExpiresAt: expiresAt, ShareID: "share-1"}, nil) body, err := json.Marshal(VerifyPinRequest{Pin: "123456", PermissionID: "share-1"}) require.NoError(t, err) @@ -59,6 +60,7 @@ func TestVerifyPinHandler(t *testing.T) { var resp verifyPinResponse require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) assert.Equal(t, "share-1", resp.PermissionID) + assert.Equal(t, "2026-10-15T08:30:00Z", resp.ExpirationDateTime.Format(time.RFC3339)) } func TestVerifyPinHandlerErrorMapping(t *testing.T) { diff --git a/services/auth-guest/pkg/server/http/verify_token.go b/services/auth-guest/pkg/server/http/verify_token.go index e2255dbdbc..53f6d92fa6 100644 --- a/services/auth-guest/pkg/server/http/verify_token.go +++ b/services/auth-guest/pkg/server/http/verify_token.go @@ -6,6 +6,7 @@ package http import ( "encoding/json" "net/http" + "time" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" @@ -23,7 +24,8 @@ type VerifyTokenRequest struct { } type sessionResponse struct { - PermissionID string `json:"permissionId"` + PermissionID string `json:"permissionId"` + ExpirationDateTime time.Time `json:"expirationDateTime"` } // VerifyTokenHandler validates the token submitted to the verify token endpoint. @@ -54,7 +56,7 @@ func VerifyTokenHandler(log log.Logger, s authguest.AuthGuest, cfg *config.Confi setSessionCookie(w, cfg, result.SessionToken) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(sessionResponse{PermissionID: result.ShareID}) + _ = json.NewEncoder(w).Encode(sessionResponse{PermissionID: result.ShareID, ExpirationDateTime: result.ExpiresAt.UTC()}) } } diff --git a/services/auth-guest/pkg/server/http/verify_token_test.go b/services/auth-guest/pkg/server/http/verify_token_test.go index 05c39e64de..ae75098767 100644 --- a/services/auth-guest/pkg/server/http/verify_token_test.go +++ b/services/auth-guest/pkg/server/http/verify_token_test.go @@ -35,7 +35,8 @@ func newVerifyTokenHandler(t *testing.T, svc authguest.AuthGuest) http.HandlerFu func TestVerifyTokenHandler(t *testing.T) { svcMock := mocks.NewAuthGuest(t) - svcMock.On("VerifyToken", mock.Anything, "valid-token").Return(&authguest.SessionResponse{SessionToken: "session-token", ShareID: "share-1"}, nil) + expiresAt := time.Date(2026, 10, 15, 10, 30, 0, 0, time.FixedZone("CEST", 2*60*60)) + svcMock.On("VerifyToken", mock.Anything, "valid-token").Return(&authguest.SessionResponse{SessionToken: "session-token", ExpiresAt: expiresAt, ShareID: "share-1"}, nil) body, err := json.Marshal(VerifyTokenRequest{Token: "valid-token"}) require.NoError(t, err) @@ -60,6 +61,7 @@ func TestVerifyTokenHandler(t *testing.T) { var resp sessionResponse require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) assert.Equal(t, "share-1", resp.PermissionID) + assert.Equal(t, "2026-10-15T08:30:00Z", resp.ExpirationDateTime.Format(time.RFC3339)) } func TestVerifyTokenHandlerErrorMapping(t *testing.T) { diff --git a/services/auth-guest/pkg/service/authguest/service.go b/services/auth-guest/pkg/service/authguest/service.go index 1a99544bed..70d59e4cb9 100644 --- a/services/auth-guest/pkg/service/authguest/service.go +++ b/services/auth-guest/pkg/service/authguest/service.go @@ -45,10 +45,11 @@ type GuestError struct { func (e *GuestError) Error() string { return e.ErrorType.Error() } -// SessionResponse is the result of a successful authentication: a session token -// and the share id it grants access to. +// SessionResponse is the result of a successful authentication: a session token, +// its expiry and the share id it grants access to. type SessionResponse struct { SessionToken string + ExpiresAt time.Time ShareID string } @@ -133,12 +134,12 @@ func (s *AuthGuestService) VerifyToken(ctx context.Context, tokenString string) return nil, &GuestError{ErrorType: err, ShareID: rec.ShareID} } - sessionToken, err := s.jwtService.Sign(rec.ShareID) + sessionToken, expiresAt, err := s.jwtService.Sign(rec.ShareID) if err != nil { return nil, err } - return &SessionResponse{SessionToken: sessionToken, ShareID: rec.ShareID}, nil + return &SessionResponse{SessionToken: sessionToken, ExpiresAt: expiresAt, ShareID: rec.ShareID}, nil } // Renew generates a new guest link token and PIN for shareID, authorized by the @@ -249,12 +250,12 @@ func (s *AuthGuestService) VerifyPin(ctx context.Context, shareID, pinValue stri return nil, &GuestError{ErrorType: err, ShareID: shareID} } - sessionToken, err := s.jwtService.Sign(shareID) + sessionToken, expiresAt, err := s.jwtService.Sign(shareID) if err != nil { return nil, err } - return &SessionResponse{SessionToken: sessionToken, ShareID: shareID}, nil + return &SessionResponse{SessionToken: sessionToken, ExpiresAt: expiresAt, ShareID: shareID}, nil } // CleanupShare removes a share's token record from storage. Missing records are ignored. diff --git a/services/auth-guest/pkg/service/authguest/service_test.go b/services/auth-guest/pkg/service/authguest/service_test.go index fa8db2fde4..a881db9cc1 100644 --- a/services/auth-guest/pkg/service/authguest/service_test.go +++ b/services/auth-guest/pkg/service/authguest/service_test.go @@ -317,6 +317,7 @@ func TestVerifyTokenSuccess(t *testing.T) { result, err := s.VerifyToken(context.Background(), tok) require.NoError(t, err) require.NotEmpty(t, result.SessionToken) + assert.WithinDuration(t, time.Now().Add(time.Hour), result.ExpiresAt, 2*time.Second) assert.Equal(t, testShareID, result.ShareID) got, err := store.Get(rec.ShareIDHash) @@ -438,7 +439,7 @@ func TestRenewWithExpiredSessionToken(t *testing.T) { require.NoError(t, store.Add(existingRecord())) jwtSvc := jwt.NewJwtService("test-secret", -time.Hour) - sessionToken, err := jwtSvc.Sign(testShareID) + sessionToken, _, err := jwtSvc.Sign(testShareID) require.NoError(t, err) publisher := &testPublisher{} @@ -477,7 +478,7 @@ 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") + otherSession, _, err := jwt.NewJwtService("test-secret", 24*time.Hour).Sign("other-share") require.NoError(t, err) otherToken, _ := newTokenFor(t, "other-share") @@ -652,6 +653,7 @@ func TestVerifyPinSuccess(t *testing.T) { result, err := s.VerifyPin(context.Background(), testShareID, "123456") require.NoError(t, err) assert.NotEmpty(t, result.SessionToken) + assert.WithinDuration(t, time.Now().Add(time.Hour), result.ExpiresAt, 2*time.Second) assert.Equal(t, testShareID, result.ShareID) got, err := store.Get(token.Hash(testShareID)) diff --git a/services/auth-guest/pkg/service/jwt/jwt.go b/services/auth-guest/pkg/service/jwt/jwt.go index 49672c7796..2f4395cf35 100644 --- a/services/auth-guest/pkg/service/jwt/jwt.go +++ b/services/auth-guest/pkg/service/jwt/jwt.go @@ -26,8 +26,8 @@ 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) { +// Sign returns a signed jwt token for the given share and its expiry. +func (m *JwtService) Sign(shareID string) (string, time.Time, error) { now := time.Now() claims := jwtClaims{ ShareID: shareID, @@ -36,7 +36,11 @@ func (m *JwtService) Sign(shareID string) (string, error) { ExpiresAt: jwt.NewNumericDate(now.Add(m.ttl)), }, } - return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret) + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret) + if err != nil { + return "", time.Time{}, err + } + return signed, claims.ExpiresAt.Time, nil } // Verify returns the share id of a session token, ignoring its expiry so an diff --git a/services/auth-guest/pkg/service/jwt/jwt_test.go b/services/auth-guest/pkg/service/jwt/jwt_test.go index 9f545bedd2..8270bd9ba3 100644 --- a/services/auth-guest/pkg/service/jwt/jwt_test.go +++ b/services/auth-guest/pkg/service/jwt/jwt_test.go @@ -24,19 +24,21 @@ func parseClaims(t *testing.T, m *JwtService, tokenString string) (*jwtClaims, e func TestSignAndParse(t *testing.T) { m := NewJwtService("test-secret", time.Hour) - tok, err := m.Sign("share-id") + tok, expiresAt, err := m.Sign("share-id") require.NoError(t, err) require.NotEmpty(t, tok) + assert.WithinDuration(t, time.Now().Add(time.Hour), expiresAt, 2*time.Second) claims, err := parseClaims(t, m, tok) require.NoError(t, err) assert.Equal(t, "share-id", claims.ShareID) + assert.True(t, claims.ExpiresAt.Equal(expiresAt)) } func TestParseExpired(t *testing.T) { m := NewJwtService("test-secret", -time.Minute) - tok, err := m.Sign("share-id") + tok, _, err := m.Sign("share-id") require.NoError(t, err) _, err = parseClaims(t, m, tok) @@ -46,7 +48,7 @@ func TestParseExpired(t *testing.T) { func TestVerify(t *testing.T) { m := NewJwtService("test-secret", time.Hour) - tok, err := m.Sign("share-id") + tok, _, err := m.Sign("share-id") require.NoError(t, err) shareID, err := m.Verify(tok) @@ -57,7 +59,7 @@ func TestVerify(t *testing.T) { func TestVerifyExpired(t *testing.T) { m := NewJwtService("test-secret", -time.Minute) - tok, err := m.Sign("share-id") + tok, _, err := m.Sign("share-id") require.NoError(t, err) shareID, err := m.Verify(tok) @@ -68,10 +70,10 @@ func TestVerifyExpired(t *testing.T) { func TestVerifyInvalid(t *testing.T) { m := NewJwtService("test-secret", time.Hour) - tok, err := m.Sign("share-id") + tok, _, err := m.Sign("share-id") require.NoError(t, err) - other, err := NewJwtService("other-secret", time.Hour).Sign("share-id") + other, _, err := NewJwtService("other-secret", time.Hour).Sign("share-id") require.NoError(t, err) tests := []struct {