Files
multica/server/internal/handler/auth_google_error_code_test.go
1fcdff78d0 MUL-7436 feat(auth): slide UI session expiry instead of forcing a re-login every 30 days (#8486)
* feat(auth): slide UI session expiry instead of fixing it at login

A session token's exp was stamped once at login and never moved, so every
continuously active user was logged out 30 days later, always mid-task. The
session now re-issues itself once it drops below half its TTL, turning that
scheduled logout into an idle bound.

Renewal is stateless: it copies the claims of a token we signed and moves
exp, so there is no session table and no DB read on the hot path.

- Browsers are renewed inline by middleware.Auth on safe requests, keeping
  the session HttpOnly — no client change and no multi-tab coordination.
- Clients holding the session as a string (Desktop, mobile) get
  POST /api/auth/refresh, which only ever accepts a UI JWT. PATs, agent task
  tokens and cloud-node PATs are refused: a machine credential must not
  become an interactive session. An expired JWT cannot be revived.
- CSRF tokens are now bound to a new sid claim, stable across renewals, so
  re-issuing the auth cookie no longer invalidates a CSRF token another tab
  already read. The pre-existing binding stays valid so sessions that
  predate this keep working until they renew.
- CloudFront cookies are re-signed alongside a renewal, so the CDN policy
  cannot outlive its session.

The idle bound is one-sided and the comments say so: a session last used
while it still had over half its TTL left is not extended, so the
guaranteed survivable gap is TTL/2, not TTL.

Refs MUL-7436

Co-authored-by: multica-agent <[email protected]>

* feat(auth): renew the session on Desktop, mobile, and legacy token-mode web

Completes the client half of sliding sessions. Browsers on cookie auth need
nothing — the server re-issues their cookie inline — so this covers the
clients that carry the session as a string and have to persist a new one.

Renewal follows use, not a clock. Checks run at launch, on returning to the
foreground, and on interaction; there is deliberately no interval timer,
because a timer would keep an abandoned but still-open window (or a
backgrounded phone) signed in forever. The server decides whether a session
is renewable and tells the client when to ask again, derived from the
deployment's configured TTL — so no client reads exp, hardcodes a cadence,
or has to reason about clock skew.

Renewal is not a session event, and each step protects that:
- Concurrent triggers collapse into one request, so two valid tokens never
  race to be the one stored. On mobile the single-flight flag is set before
  the first await, since reading the Keychain is itself async.
- A response that lands after logout or an account switch is discarded by
  comparing against the session the attempt started from; writing it back
  would resurrect a session the app has already torn down.
- Offline, 5xx and an unreadable body all keep the current session, which is
  still valid for at least another half TTL. Only a real 401 ends a session,
  and that path is untouched — nothing here can clear tabs or drafts.
- WebSocket clients now read the token at connect time instead of pinning
  the one captured when they were built, so a reconnect after a renewal
  authenticates with the current credential.

Web also retries a rejected CSRF token once. Session-bound CSRF makes that
unreachable in the steady state; it covers the single transition where a
session predating that binding is renewed for the first time.

Refs MUL-7436

Co-authored-by: multica-agent <[email protected]>

* fix(auth): address review blockers on sliding session renewal

Six fixes from review, each with a regression test that fails without it.

An expired session was answered 403, not 401. The CSRF gate runs before the
JWT is validated, and it read `sid` through the expiry-enforcing parser — so
an expired cookie lost its sid, its perfectly good CSRF token failed to
verify, and the user's first action after expiry was a rejected write instead
of a return to the login page. Reading the session id and deciding access are
now different questions with different parsers: the first only needs the
signature, the second still enforces expiry.

Rolling back was not survivable. A previous release can verify only the
token-bound CSRF binding, so once a session was issued or renewed by this
one, a rollback left every signed-in user able to read and unable to write,
with no client retry able to help. Both bindings are now issued: clients echo
both, the server accepts either, and the token-bound cookie is re-minted on
every renewal so a rollback taken at any moment still finds a usable pair.

Mobile could resurrect a session it had just ended. Keychain deletes are
async, so a renewal that sampled storage after logout began but before the
delete landed would write its replacement back. A session epoch, incremented
synchronously at the start of logout, a 401 teardown and a new sign-in, closes
the window comparing tokens cannot — including the case where logout lands
during the renewal's own write, which is now undone rather than left behind.

Mobile only checked at launch and on foreground, so an app held in the
foreground and used continuously would never check again and could expire
under the user's hands. Every touch now passes through a capture-phase
responder that declines the gesture it observes.

CloudFront cookies were re-signed by a middleware not every renewing route
mounts — the plugin bridge renews without it, and would have slid the session
forward while leaving the CDN policy behind. Re-signing moved next to the
renewal itself, so the two cannot diverge by route.

The renewal check interval could exceed the window it has to land in: a flat
five-minute floor meant any TTL under ten minutes produced a cadence longer
than the renewal window, logging out clients that followed it exactly. The
cadence is now capped at half the threshold, which outranks both clamps.

Refs MUL-7436

Co-authored-by: multica-agent <[email protected]>

* fix(auth): close the CORS, credential-write and short-TTL gaps

Three blockers from the second review round.

The two CSRF values now travel in one header instead of two. A second header
name would have to be in the server's CORS allowlist, and that list is
version-specific: a browser holding a cookie this release issued, talking to a
rolled-back server, would send a header that server never allowlisted. The
preflight fails, the request never reaches a handler, and no retry helps —
precisely the half-logged-in state the second cookie exists to prevent. With
one header name the server just tries both bindings, and the client switches
to the token-bound value when a server rejects the session-bound one. It
switches back as soon as the cookie changes, keyed on the rejected value
rather than a flag, so it cannot oscillate once per renewal. A test now pins
the CSRF header to the allowlist so a future second header fails here rather
than in production.

Mobile credential writes are serialized, and a stale renewal is refused rather
than undone. Compensating after the fact meant deleting whatever was stored —
which, after a logout and a fresh sign-in, is the new account's token: the app
would be logged in in memory and signed out on the next launch. The check and
the write now happen inside one serialized unit, so a logout or a sign-in
cannot land between them and a refusal writes nothing at all.

AUTH_TOKEN_TTL has a documented one-minute minimum. Clients are told when to
check again in whole seconds, so a shorter TTL asks the wire to carry a
sub-second cadence: it truncates, and below about four seconds it serialises
to 0. Values under the minimum are clamped up with a startup warning rather
than honoured. Mobile's floors were never actually lowered in the previous
commit — 1h/1m, not the 5s/5m claimed — so both clients now floor at 5s, below
anything the server can send, and fall back to 30s, which fits the smallest
supported window. The cadence tests assert the serialised integer a client
actually receives, not the internal time.Duration.

Refs MUL-7436

Co-authored-by: multica-agent <[email protected]>

* fix(auth): keep a failed renewal cheap and a renewed token shared

Two blockers from the full-PR review, plus the documentation the config
change owes.

A failed check no longer consumes the normal cadence. The two were one
number, so a single 503 spent whatever the server had last suggested — with
the 30-day default that is three days. A client told to check again in three
days on day 12, returning on day 29 with one day of session left and hitting
one 503, would not have been allowed to retry until day 32: two days after the
session it was trying to save had expired. Failures now buy a short backing-off
delay of their own, reset by the next success, so recovery follows the network
rather than the cadence.

The bearer token is read from shared storage per request instead of cached per
client. Desktop runs one ApiClient per window over one localStorage, so a
session renewed in one window left the others sending a credential on its way
out until it expired and took the whole session down. A 401 is also now matched
against the credential that produced it: a request that went out just before a
renewal can be answered after it, and treating that as expiry would tear down a
session that is demonstrably alive, clearing tabs and drafts with it.

Documentation catches up with both the behaviour and the new minimum, in all
four languages: sessions are an idle bound rather than a countdown from
sign-in, the bound is one-sided, and AUTH_TOKEN_TTL clamps up at 60s. The
rationale for that minimum is corrected too — it is the 5s floor every client
applies to the derived cadence, not whole-second serialisation, which only
binds far below it.

Also removes two claims that were not true: that a failed renewal leaves at
least half a TTL (it happens inside the window, so less), and that an open
untouched window never renews (cookie-mode sessions slide on any authenticated
request, background polling included).

Refs MUL-7436

Co-authored-by: multica-agent <[email protected]>

* fix(auth): back off on a renewal response that carries no cadence

A 200 whose body fails schema validation does not throw — parseWithFallback
returns zeroes — so both clients reset the failure count without moving the
next-check deadline, and every subsequent interaction fired another request at
a server already answering badly. A missing cadence is now treated as a
failure and takes the same short backoff; a token that did arrive is still
applied.

Also settles the documentation wording: the 30 days is not an idle timer.
Renewal only happens past the halfway point, so an unused session can end
sooner than 30 days after its last use, and saying otherwise promised
precision the design does not have.

Refs MUL-7436

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-16 18:44:59 +08:00

323 lines
13 KiB
Go

package handler
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/iotest"
"github.com/jackc/pgx/v5"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/testutil"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type googleRoundTripper func(*http.Request) (*http.Response, error)
func (fn googleRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func googleResponse(req *http.Request, status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}
}
func TestGoogleLoginTokenFailures(t *testing.T) {
t.Setenv("GOOGLE_CLIENT_ID", "test-client")
t.Setenv("GOOGLE_CLIENT_SECRET", "test-secret")
tests := []struct {
name string
status int
body string
readErr error
requestErr error
wantStatus int
wantCode string
}{
{name: "invalid grant", status: 400, body: `{"error":"invalid_grant"}`, wantStatus: 400, wantCode: "oauth_code_invalid"},
{name: "invalid client", status: 401, body: `{"error":"invalid_client"}`, wantStatus: 502},
{name: "redirect mismatch", status: 400, body: `{"error":"redirect_uri_mismatch"}`, wantStatus: 502},
{name: "unauthorized client", status: 400, body: `{"error":"unauthorized_client"}`, wantStatus: 502},
{name: "deleted client", status: 401, body: `{"error":"deleted_client"}`, wantStatus: 502},
{name: "unknown provider error", status: 400, body: `{"error":"future_error"}`, wantStatus: 502},
{name: "rate limited", status: 429, body: `{"error":"rate_limit_exceeded"}`, wantStatus: 502},
{name: "provider unavailable", status: 503, body: `{"error":"temporarily_unavailable"}`, wantStatus: 502},
{name: "5xx takes precedence over invalid grant", status: 500, body: `{"error":"invalid_grant"}`, wantStatus: 502},
{name: "malformed provider error", status: 400, body: `<html>bad gateway</html>`, wantStatus: 502},
{name: "partially decoded provider error", status: 400, body: `{"error":"invalid_grant","error":42}`, wantStatus: 502},
{name: "empty provider error", status: 400, wantStatus: 502},
{name: "malformed token", status: 200, body: `{`, wantStatus: 502},
{name: "missing access token", status: 200, body: `{}`, wantStatus: 502},
{name: "null token", status: 200, body: `null`, wantStatus: 502},
{name: "empty access token", status: 200, body: `{"access_token":""}`, wantStatus: 502},
{name: "wrong access token type", status: 200, body: `{"access_token":42}`, wantStatus: 502},
{name: "read failure", status: 200, readErr: io.ErrUnexpectedEOF, wantStatus: 502},
{name: "transport failure", requestErr: errors.New("provider unavailable"), wantStatus: 502},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := newTestHandler(Config{})
h.googleOAuthHTTPClient = &http.Client{Transport: googleRoundTripper(func(req *http.Request) (*http.Response, error) {
if req.URL.Host != "oauth2.googleapis.com" {
t.Fatalf("token failure must not fetch userinfo: %s", req.URL)
}
if tt.requestErr != nil {
return nil, tt.requestErr
}
resp := googleResponse(req, tt.status, tt.body)
if tt.readErr != nil {
resp.Body = io.NopCloser(iotest.ErrReader(tt.readErr))
}
return resp, nil
})}
req := httptest.NewRequest(http.MethodPost, "/auth/google", strings.NewReader(`{"code":"test-code"}`))
var got struct {
Error string `json:"error"`
Code string `json:"code"`
}
testutil.Call(t, h.GoogleLogin, req).Want(tt.wantStatus).JSON(&got)
if got.Code != tt.wantCode || got.Error == "" {
t.Fatalf("got code=%q error=%q, want code=%q and a fallback message", got.Code, got.Error, tt.wantCode)
}
})
}
}
func TestGoogleLoginUserInfoFailures(t *testing.T) {
t.Setenv("GOOGLE_CLIENT_ID", "test-client")
t.Setenv("GOOGLE_CLIENT_SECRET", "test-secret")
tests := []struct {
name string
status int
body string
readErr error
requestErr error
wantStatus int
wantCode string
}{
{name: "unauthorized", status: 401, body: `{"error":{"code":401,"status":"UNAUTHENTICATED"}}`, wantStatus: 502},
{name: "forbidden", status: 403, body: `{"error":{"code":403}}`, wantStatus: 502},
{name: "rate limited", status: 429, body: `{"error":{"code":429}}`, wantStatus: 502},
{name: "provider unavailable", status: 503, body: `{"error":{"code":503}}`, wantStatus: 502},
{name: "error status with profile fields", status: 500, body: `{"email":"[email protected]"}`, wantStatus: 502},
{name: "non-JSON provider failure", status: 502, body: `<html>bad gateway</html>`, wantStatus: 502},
{name: "missing email on a successful response", status: 200, body: `{"name":"No Email"}`, wantStatus: 400, wantCode: "google_account_no_email"},
{name: "blank email on a successful response", status: 200, body: `{"email":" "}`, wantStatus: 400, wantCode: "google_account_no_email"},
{name: "null profile", status: 200, body: `null`, wantStatus: 502},
{name: "malformed profile", status: 200, body: `{`, wantStatus: 502},
{name: "wrong email type", status: 200, body: `{"email":42}`, wantStatus: 502},
{name: "empty profile response", status: 200, wantStatus: 502},
{name: "read failure", status: 200, readErr: io.ErrUnexpectedEOF, wantStatus: 502},
{name: "unreadable provider failure", status: 503, readErr: io.ErrUnexpectedEOF, wantStatus: 502},
{name: "transport failure", requestErr: errors.New("provider unavailable"), wantStatus: 502},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := newTestHandler(Config{})
h.googleOAuthHTTPClient = &http.Client{Transport: googleRoundTripper(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case "oauth2.googleapis.com":
return googleResponse(req, http.StatusOK, `{"access_token":"test-token"}`), nil
case "www.googleapis.com":
if tt.requestErr != nil {
return nil, tt.requestErr
}
resp := googleResponse(req, tt.status, tt.body)
if tt.readErr != nil {
resp.Body = io.NopCloser(iotest.ErrReader(tt.readErr))
}
return resp, nil
default:
t.Fatalf("unexpected Google OAuth request: %s", req.URL)
return nil, nil
}
})}
req := httptest.NewRequest(http.MethodPost, "/auth/google", strings.NewReader(`{"code":"test-code"}`))
var got struct {
Error string `json:"error"`
Code string `json:"code"`
}
testutil.Call(t, h.GoogleLogin, req).Want(tt.wantStatus).JSON(&got)
if got.Code != tt.wantCode || got.Error == "" {
t.Fatalf("got code=%q error=%q, want code=%q and a fallback message", got.Code, got.Error, tt.wantCode)
}
})
}
}
func TestGoogleLoginActionableErrorCodes(t *testing.T) {
t.Setenv("GOOGLE_CLIENT_ID", "test-client")
t.Setenv("GOOGLE_CLIENT_SECRET", "test-secret")
tests := []struct {
name string
cfg Config
userBody string
wantStatus int
wantCode string
wantError string
}{
{
name: "signup prohibited",
cfg: Config{AllowSignup: false},
userBody: `{"email":"[email protected]"}`,
wantStatus: http.StatusForbidden,
wantCode: "signup_prohibited",
wantError: "user registration is disabled on this self-hosted instance",
},
{
name: "email not allowed",
cfg: Config{AllowSignup: true, AllowedEmailDomains: []string{"company.com"}},
userBody: `{"email":"[email protected]"}`,
wantStatus: http.StatusForbidden,
wantCode: "email_not_allowed",
wantError: "email address or domain not allowed on this instance",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := newTestHandler(tt.cfg)
h.Queries = db.New(&mockDB{getUserErr: pgx.ErrNoRows})
h.googleOAuthHTTPClient = &http.Client{Transport: googleRoundTripper(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case "oauth2.googleapis.com":
return googleResponse(req, http.StatusOK, `{"access_token":"test-token"}`), nil
case "www.googleapis.com":
return googleResponse(req, http.StatusOK, tt.userBody), nil
default:
t.Fatalf("unexpected Google OAuth request: %s", req.URL)
return nil, nil
}
})}
req := httptest.NewRequest(
http.MethodPost,
"/auth/google",
bytes.NewBufferString(`{"code":"test-code","redirect_uri":"http://localhost/auth/callback"}`),
)
var got struct {
Error string `json:"error"`
Code string `json:"code"`
}
testutil.Call(t, h.GoogleLogin, req).Want(tt.wantStatus).JSON(&got)
if got.Code != tt.wantCode || got.Error != tt.wantError {
t.Fatalf("got code=%q error=%q, want code=%q error=%q", got.Code, got.Error, tt.wantCode, tt.wantError)
}
})
}
}
func TestGoogleLoginActionableErrorMapping(t *testing.T) {
tests := []struct {
name string
err error
wantCode string
wantError string
}{
{"account disabled", auth.ErrTemporarilyDisabledUser, "account_disabled", "account disabled"},
{"signup prohibited", ErrSignupProhibited, "signup_prohibited", ErrSignupProhibited.Error()},
{"email not allowed", ErrEmailNotAllowed, "email_not_allowed", ErrEmailNotAllowed.Error()},
{"wrapped signup restriction", fmt.Errorf("signup: %w", ErrEmailNotAllowed), "email_not_allowed", ErrEmailNotAllowed.Error()},
{"future signup restriction", SignupError{Message: "another signup restriction"}, "", "another signup restriction"},
{"wrapped future signup restriction", fmt.Errorf("signup: %w", SignupError{Message: "another signup restriction"}), "", "another signup restriction"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got struct {
Error string `json:"error"`
Code string `json:"code"`
}
req := httptest.NewRequest(http.MethodGet, "/auth/google", nil)
testutil.Call(t, func(w http.ResponseWriter, _ *http.Request) {
if !writeGoogleLoginActionableError(w, tt.err) {
t.Fatalf("actionable error was not handled: %v", tt.err)
}
}, req).Want(http.StatusForbidden).JSON(&got)
if got.Code != tt.wantCode || got.Error != tt.wantError {
t.Fatalf("got code=%q error=%q, want code=%q error=%q", got.Code, got.Error, tt.wantCode, tt.wantError)
}
})
}
}
func TestGoogleLoginSuccessfulExistingUser(t *testing.T) {
t.Setenv("GOOGLE_CLIENT_ID", "test-client")
t.Setenv("GOOGLE_CLIENT_SECRET", "test-secret")
email := "[email protected]"
userID := dbfx.User(t, "Google OAuth User", email)
// Existing users can still sign in when new registrations are restricted.
h := newTestHandler(Config{AllowSignup: false, AllowedEmailDomains: []string{"company.com"}})
h.Queries = testHandler.Queries
requests := 0
h.googleOAuthHTTPClient = &http.Client{Transport: googleRoundTripper(func(req *http.Request) (*http.Response, error) {
requests++
switch req.URL.Host {
case "oauth2.googleapis.com":
if err := req.ParseForm(); err != nil {
t.Fatal(err)
}
if req.Form.Get("code") != "test-code" || req.Form.Get("redirect_uri") != "http://localhost/auth/callback" {
t.Fatalf("unexpected token exchange form: %v", req.Form)
}
return googleResponse(req, http.StatusOK, `{"access_token":"test-token"}`), nil
case "www.googleapis.com":
if req.Header.Get("Authorization") != "Bearer test-token" {
t.Fatal("userinfo request did not use the exchanged token")
}
return googleResponse(req, http.StatusOK, `{"email":" [email protected] "}`), nil
default:
t.Fatalf("unexpected Google OAuth request: %s", req.URL)
return nil, nil
}
})}
req := httptest.NewRequest(http.MethodPost, "/auth/google", strings.NewReader(`{"code":"test-code","redirect_uri":"http://localhost/auth/callback"}`))
var got LoginResponse
resp := testutil.Call(t, h.GoogleLogin, req).Want(http.StatusOK).JSON(&got)
if requests != 2 || got.Token == "" || got.User.ID != userID || got.User.Email != email {
t.Fatalf("unexpected successful login: requests=%d, token present=%t, user=%+v", requests, got.Token != "", got.User)
}
var authCookie, csrfCookie *http.Cookie
for _, cookie := range resp.Result().Cookies() {
switch cookie.Name {
case auth.AuthCookieName:
authCookie = cookie
case auth.CSRFCookieName:
csrfCookie = cookie
}
}
if authCookie == nil || authCookie.Value != got.Token || csrfCookie == nil || csrfCookie.Value == "" {
t.Fatal("successful Google login must return matching auth and CSRF cookies")
}
// Exercise the issued JWT through the same middleware that accepts browser sessions.
meReq := httptest.NewRequest(http.MethodGet, "/users/me", nil)
meReq.AddCookie(authCookie)
protected := middleware.Auth(h.Queries, nil, nil, nil)(http.HandlerFunc(h.GetMe))
var me UserResponse
testutil.Call(t, protected.ServeHTTP, meReq).Want(http.StatusOK).JSON(&me)
if me.ID != userID || me.Email != email {
t.Fatalf("Google session resolved to the wrong user: %+v", me)
}
csrfReq := httptest.NewRequest(http.MethodPost, "/users/me", nil)
csrfReq.AddCookie(authCookie)
csrfReq.Header.Set("X-CSRF-Token", csrfCookie.Value)
if !auth.ValidateCSRF(csrfReq) {
t.Fatal("Google login cookies must allow authenticated browser writes")
}
}