Files
multica/server/internal/handler/session.go
Claude 396752349f feat(server): platform admin API with audit log and DB-backed disabled users
Adds the backend for the operations console: platform_admin roles
(super_admin / operator / viewer), an admin_audit_log table, and
user.disabled_at so disabling an account is a stored state instead of a
hard-coded denylist. The in-process disabled set is now refreshed from the
database every 30 seconds, and the old emergency denylist entries are
migrated into it.

/api/admin exposes overview counts, user and workspace listings with
detail, disable/enable, token revocation, the audit log, and platform
role management. Every write requires a reason and is recorded with the
actor, IP and user agent. PLATFORM_ADMIN_EMAILS bootstraps the first
super_admin.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FrVjAFWeckYV69HS51xUhy
2026-10-31 10:05:25 +00:00

164 lines
6.0 KiB
Go

package handler
import (
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/logger"
)
// RefreshSessionResponse is the body returned by RefreshSession.
//
// Token is present only for bearer-authenticated callers (Desktop, mobile),
// which hold the session as a string and have to persist the new one. A
// cookie-authenticated browser gets the renewed session as a Set-Cookie
// header and never sees the JWT — handing it back in a readable body would
// undo the point of the HttpOnly cookie.
//
// Renewed=false is a normal answer, not an error: it means the caller asked
// before the session entered its renewal window. Callers should read
// CheckAgainInSeconds rather than deciding a cadence for themselves.
type RefreshSessionResponse struct {
Token string `json:"token,omitempty"`
ExpiresAt string `json:"expires_at"`
Renewed bool `json:"renewed"`
CheckAgainInSeconds int `json:"check_again_in_seconds"`
}
// RefreshSession extends the interactive session that authenticated this
// request, when that session is inside its renewal window.
//
// Browsers do not need this endpoint — the auth middleware re-issues their
// cookie inline on any authenticated safe request. It exists for the clients
// that carry the session as a string and therefore need the new string back:
// Desktop (localStorage) and mobile (Keychain). They call it at launch, when
// returning to the foreground, and on use; the server owns the "is it time
// yet" decision so no client ever has to read `exp` or trust its own clock.
//
// Only our own UI session JWTs are refreshable. A PAT (mul_), an agent task
// token (mat_) and a cloud-node PAT (mcn_) all authenticate perfectly well at
// the middleware, and every one of them must be refused here: none of them is
// an interactive session, and exchanging a machine credential for one would
// turn a scoped, revocable token into an unscoped, unrevocable one. An
// expired JWT cannot reach this handler at all (the middleware rejects it),
// and ShouldRenewSession refuses a non-positive remaining lifetime anyway —
// renewal extends a live session, it never resurrects a dead one.
func (h *Handler) RefreshSession(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
// The middleware resolved the credential to a user but does not pass the
// raw token forward, and renewal needs the claims themselves. Re-read it
// from the same two places extractToken looks, in the same order.
rawToken, fromCookie := sessionTokenFromRequest(r)
if rawToken == "" {
writeError(w, http.StatusBadRequest, "only interactive sessions can be refreshed")
return
}
claims, err := auth.ParseSessionToken(rawToken)
if err != nil {
// Reached by PAT / task-token / cloud-PAT callers: authenticated, but
// holding something that is not a session. 400 rather than 401 — the
// credential is fine, the request is not.
writeError(w, http.StatusBadRequest, "only interactive sessions can be refreshed")
return
}
// Defense in depth, mirroring RenewCurrentPersonalAccessToken: the
// middleware set X-User-ID from these very claims, so a mismatch means a
// header was forged past it. Refuse to mint a session for someone else.
if sub, _ := claims["sub"].(string); sub != userID {
writeError(w, http.StatusUnauthorized, "token does not belong to caller")
return
}
checkAgain := int(auth.SessionRenewCheckInterval().Seconds())
expiresAt := auth.SessionExpiry(claims)
if !auth.ShouldRenewSession(time.Now(), expiresAt) {
writeJSON(w, http.StatusOK, RefreshSessionResponse{
ExpiresAt: formatSessionExpiry(expiresAt),
Renewed: false,
CheckAgainInSeconds: checkAgain,
})
return
}
newToken, newExpiresAt, err := auth.RenewSessionToken(claims)
if err != nil {
if errors.Is(err, auth.ErrDisabledUser) {
writeError(w, http.StatusForbidden, auth.DisabledUserError)
return
}
slog.Warn("session refresh: failed to re-sign token",
append(logger.RequestAttrs(r), "error", err, "user_id", userID)...)
// The caller's current session is still valid — this failure costs it
// nothing but a retry, so answer with the unrenewed state rather than
// an error a client might mistake for a dead session.
writeJSON(w, http.StatusOK, RefreshSessionResponse{
ExpiresAt: formatSessionExpiry(expiresAt),
Renewed: false,
CheckAgainInSeconds: checkAgain,
})
return
}
resp := RefreshSessionResponse{
ExpiresAt: formatSessionExpiry(newExpiresAt),
Renewed: true,
CheckAgainInSeconds: checkAgain,
}
if fromCookie {
if err := auth.SetAuthCookies(w, newToken); err != nil {
slog.Warn("session refresh: failed to set auth cookies",
append(logger.RequestAttrs(r), "error", err)...)
writeJSON(w, http.StatusOK, RefreshSessionResponse{
ExpiresAt: formatSessionExpiry(expiresAt),
Renewed: false,
CheckAgainInSeconds: checkAgain,
})
return
}
if h.CFSigner != nil {
for _, cookie := range h.CFSigner.SignedCookies(newExpiresAt) {
http.SetCookie(w, cookie)
}
}
} else {
resp.Token = newToken
}
writeJSON(w, http.StatusOK, resp)
}
// sessionTokenFromRequest mirrors middleware.extractToken: Authorization
// header first, auth cookie second. The bool reports the cookie path, which
// is what decides whether the renewed token may be written into the response
// body.
func sessionTokenFromRequest(r *http.Request) (token string, fromCookie bool) {
if authHeader := r.Header.Get("Authorization"); authHeader != "" {
if tokenString := strings.TrimPrefix(authHeader, "Bearer "); tokenString != authHeader {
return tokenString, false
}
}
if cookie, err := r.Cookie(auth.AuthCookieName); err == nil && cookie.Value != "" {
return cookie.Value, true
}
return "", false
}
func formatSessionExpiry(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}