diff --git a/services/auth-guest/README.md b/services/auth-guest/README.md index 209465ad45..3fb962be55 100644 --- a/services/auth-guest/README.md +++ b/services/auth-guest/README.md @@ -34,7 +34,7 @@ links feature and start the service. PIN; redeeming a token or verifying a PIN sets a session cookie. `renew` and `verify/pin` are rate-limited. - Stores only hashes of the token secret, of the PIN and of the nonce, and - deletes the stored record when the share is removed or expires. + deletes the stored record when the share is removed or expires. ## Guest links flow @@ -209,9 +209,11 @@ sequenceDiagram publishes `GuestTokenRenewed` without a PIN; no nonce cookie is set. Each path is limited per share by its own quota (cooldown plus daily cap), and an exceeded limit fails with `429`. Without a valid session nothing happens - while the newest link is neither used nor expired. Both paths require an - existing record; otherwise the request fails with `tokenNotFound`. If - publishing fails, the previous record is restored. + while the newest link is neither used nor expired. The response body's + `withPin` reports whether the mail carries a PIN, which tells the + session-authorized path apart from the silent renewal. Both paths require an + existing record; otherwise the request fails with `tokenNotFound`. + If publishing fails, the previous record is restored. 4. **Verify PIN** — the guest posts the PIN, the `permissionId` and the nonce cookie to `POST /graph/v1beta1/extensions/org.libregraph/guestLinks/verify/pin`. @@ -245,10 +247,12 @@ Rate-limited responses (`429`) also carry a `retryAfter` field (seconds) and a - **Authorization:** the session cookie, when it carries a valid, unexpired session token for the given share; without it the renewal is silent (no PIN, no nonce cookie) -- **Success:** `200`, no session cookie. With a valid session a new link and - PIN are sent by mail and the nonce cookie is set. Without one a new link is - sent by mail, unless the newest link can still be redeemed, in which case - nothing happens; the response is the same either way. +- **Success:** `200`, no session cookie, `{ "withPin": }`. `withPin` is + `true` only when a valid session authorized the renewal and a new link and PIN + were sent by mail (the nonce cookie is set as well). In every other successful + case — silent rotation without a PIN, or nothing sent because the newest link + can still be redeemed — it is `false`, which keeps those cases + indistinguishable to callers without a session. - **Errors:** `400 invalidRequest`, `404 tokenNotFound`, `404 shareNotFound`, `409 conflict`, `410 shareExpired`, `429 tooManyRequests` (with `Retry-After`), `503 serviceUnavailable`, `500 internalError` diff --git a/services/auth-guest/pkg/server/http/renew.go b/services/auth-guest/pkg/server/http/renew.go index 73ad6dc911..a1047abb27 100644 --- a/services/auth-guest/pkg/server/http/renew.go +++ b/services/auth-guest/pkg/server/http/renew.go @@ -18,8 +18,16 @@ type RenewRequest struct { PermissionID string `json:"permissionId"` } +// RenewResponse is the response body of the renew endpoint. WithPin reports +// whether the renewal email contains a PIN, which is true exactly when the +// request was authorized by a valid session. +type RenewResponse struct { + WithPin bool `json:"withPin"` +} + // RenewHandler generates a new guest link token and PIN for the share -// identified by the permission id, authorized by the session cookie. +// identified by the permission id, authorized by the session cookie. The +// response body reports whether the renewal email contains a PIN. 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 @@ -51,6 +59,9 @@ func RenewHandler(log log.Logger, s authguest.AuthGuest, cfg *config.Config) fun setNonceCookie(w, cfg, req.PermissionID, nonce) } + // A non-empty nonce means the PIN path ran, so the mail carries a PIN. + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(RenewResponse{WithPin: nonce != ""}) } } diff --git a/services/auth-guest/pkg/server/http/renew_test.go b/services/auth-guest/pkg/server/http/renew_test.go index ef37ae560c..06d3e6c5ed 100644 --- a/services/auth-guest/pkg/server/http/renew_test.go +++ b/services/auth-guest/pkg/server/http/renew_test.go @@ -61,8 +61,13 @@ func TestRenewHandlerWithSessionCookie(t *testing.T) { newRenewHandler(t, svcMock)(rr, req) assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) svcMock.AssertCalled(t, "Renew", mock.Anything, "share-1", "session-token") + var resp RenewResponse + require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) + assert.True(t, resp.WithPin) + wantName := nonceCookieName(testNonceCookieName, "share-1") var cookie *http.Cookie for _, c := range rr.Result().Cookies() { @@ -81,7 +86,7 @@ func TestRenewHandlerWithSessionCookie(t *testing.T) { func TestRenewHandlerWithoutSessionCookie(t *testing.T) { svcMock := mocks.NewAuthGuest(t) - svcMock.On("Renew", mock.Anything, "share-1", "").Return("nonce-value", nil) + svcMock.On("Renew", mock.Anything, "share-1", "").Return("", nil) body, err := json.Marshal(RenewRequest{PermissionID: "share-1"}) require.NoError(t, err) @@ -91,6 +96,10 @@ func TestRenewHandlerWithoutSessionCookie(t *testing.T) { assert.Equal(t, http.StatusOK, rr.Code) svcMock.AssertCalled(t, "Renew", mock.Anything, "share-1", "") + + var resp RenewResponse + require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) + assert.False(t, resp.WithPin) } func TestRenewHandlerSkipsNonceCookieWhenEmpty(t *testing.T) { @@ -113,6 +122,10 @@ func TestRenewHandlerSkipsNonceCookieWhenEmpty(t *testing.T) { assert.Equal(t, http.StatusOK, rr.Code) assert.Empty(t, rr.Result().Cookies()) + + var resp RenewResponse + require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) + assert.False(t, resp.WithPin) } func TestRenewHandlerNonceCookiePerShare(t *testing.T) {