mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-10-30 18:18:37 -04:00
Merge pull request #3727 from aduffeck/guest-session-expiry
Return ExpiresAt when issueing a session token
This commit is contained in:
9 files changed
+41
-24
No files matched your search
@@ -47,7 +47,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
|
||||
@@ -77,7 +77,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;
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user