feat(auth): refresh token 续期(B2) — 不透明哈希+单次旋转+/auth/refresh+/auth/logout
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- auth/refresh.go: RefreshStore(Issue/Rotate/Revoke/RevokeAll/RevokeByUser), SHA-256哈希256-bit随机串, 单次旋转事务(并发重放fail-closed)
- 迁移0008_refresh_tokens(id/user_id/role/token_hash UNIQUE/expires_at/revoked_at + 索引)
- gateway: POST /v1/auth/refresh(旋转/续期) + /v1/auth/logout(单/全设备); login响应增refreshToken
- config: AUTH_REFRESH_TTL(720h)/AUTH_REFRESH_ROTATE_DISABLED; decode.go 支持time.Duration
- rotate-jwt成功后RevokeAll(响应refreshTokensRevoked), revoke-keys不动refresh
- 14测试+真实二进制e2e, build/vet/test绿+交叉编译
- Docs/api-client v0.25 + api-reference §2.3/2.4 同步
This commit is contained in:
AskaEth committed 2026-09-08 21:45:00 +08:00
1 parent 7ecb43bd5d
commit 026b27b310
17 files changed
+1237 -13

No files matched your search

+19 -2
View File
@@ -71,10 +71,27 @@ func (s *Server) adminRotateJWT(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrInternal, "rotate_failed", "failed to rotate jwt secret"))
return
}
// Credential rotation must invalidate every long-lived grant too, not only the
// access tokens signed under the old secret. Refresh tokens are opaque and
// secret-independent, so they are deleted wholesale — the minimal, exact
// global invalidation in this architecture (no token_version column needed).
var refreshRevoked int64
if s.refresh != nil {
n, rerr := s.refresh.RevokeAll(r.Context())
if rerr != nil {
s.log.Error("rotate-jwt revoke refresh tokens", "err", rerr)
s.auditClaims(r, "system.rotate_jwt", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_revoke_failed",
"JWT 已轮换,但 refresh token 作废失败,请重试或检查数据库"))
return
}
refreshRevoked = n
}
s.auditClaims(r, "system.rotate_jwt", "ok")
writeJSON(w, http.StatusOK, map[string]any{
"rotated": true,
"note": "现有 token 已全部失效,需重新登录",
"rotated": true,
"refreshTokensRevoked": refreshRevoked,
"note": "现有 token 与 refresh token 已全部失效,需重新登录",
})
}
+153
View File
@@ -0,0 +1,153 @@
package gateway
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// defaultRefreshTTL is the refresh-token lifetime used when cfg.Auth.RefreshTTL
// is unset (an undecoded config in tests), matching the AUTH_REFRESH_TTL default.
const defaultRefreshTTL = 720 * time.Hour
// refreshRequest is the POST /api/v1/auth/refresh body.
type refreshRequest struct {
RefreshToken string `json:"refreshToken"`
}
// refreshResponse is the refresh success payload: a fresh access token plus the
// rotated refresh token (omitted when rotation is disabled by config).
type refreshResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
Role string `json:"role"`
RefreshToken string `json:"refreshToken,omitempty"`
}
// logoutRequest is the POST /api/v1/auth/logout body.
type logoutRequest struct {
RefreshToken string `json:"refreshToken"`
}
// refreshTTL returns the configured refresh-token lifetime, falling back to
// defaultRefreshTTL when the config was not decoded (zero value).
func (s *Server) refreshTTL() time.Duration {
if s.cfg != nil && s.cfg.Auth.RefreshTTL > 0 {
return s.cfg.Auth.RefreshTTL
}
return defaultRefreshTTL
}
// refreshRotate reports whether single-use rotation is enabled. Rotation is the
// default; only an explicit AUTH_REFRESH_ROTATE_DISABLED=true opts out.
func (s *Server) refreshRotate() bool {
return s.cfg == nil || !s.cfg.Auth.RefreshRotateDisabled
}
// handleRefresh exchanges a valid opaque refresh token for a new access token,
// rotating the refresh token by default. It is public (the refresh token is the
// credential) and returns 401 invalid_grant for an unknown, revoked, or expired
// token. A successful rotation is audited; the token itself is never logged.
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
var req refreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if s.refresh == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_unavailable", "refresh token service is not configured"))
return
}
grant, newRefresh, err := s.refresh.Rotate(r.Context(), req.RefreshToken, auth.RotateOptions{
TTL: s.refreshTTL(),
Rotate: s.refreshRotate(),
})
if err != nil {
if errors.Is(err, auth.ErrInvalidGrant) {
writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "invalid_grant", "invalid refresh token"))
return
}
s.log.Error("refresh rotate", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_failed", "could not refresh token"))
return
}
token, err := s.auth.Sign(grant.UserID, grant.Role, tokenTTL)
if err != nil {
s.log.Error("refresh sign", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_failed", "could not sign token"))
return
}
s.audit(r.Context(), grant.UserID+":"+string(grant.Role), "auth.refresh", "ok")
writeJSON(w, http.StatusOK, refreshResponse{
Token: token,
ExpiresAt: time.Now().Add(tokenTTL).Unix(),
Role: string(grant.Role),
RefreshToken: newRefresh,
})
}
// handleLogout revokes the refresh token in the body. It is public and
// idempotent: an unknown or already revoked token yields {"revoked": false} with
// 200 rather than an error, so a client can always clear its local state.
//
// The body may carry either a refreshToken (revokes that one grant) or nothing,
// in which case the Authorization Bearer access token identifies the account and
// every refresh grant for it is revoked (logout on all devices). A request with
// neither is a 400.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
var req logoutRequest
// An empty body is valid: it means "no refreshToken", and the account is
// identified by the bearer access token below.
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if s.refresh == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_unavailable", "refresh token service is not configured"))
return
}
if strings.TrimSpace(req.RefreshToken) != "" {
revoked, err := s.refresh.Revoke(r.Context(), req.RefreshToken)
if err != nil {
s.log.Error("logout revoke refresh token", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "logout_failed", "could not revoke refresh token"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"revoked": revoked})
return
}
access := bearerFromRequest(r)
if access == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "refreshToken or Authorization bearer token is required"))
return
}
claims, err := s.auth.Verify(access)
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "invalid_grant", "invalid access token"))
return
}
n, err := s.refresh.RevokeByUser(r.Context(), claims.Sub)
if err != nil {
s.log.Error("logout revoke user refresh tokens", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "logout_failed", "could not revoke refresh tokens"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"revoked": n > 0, "revokedCount": n})
}
// bearerFromRequest extracts the Bearer credential from the Authorization
// header, returning "" when the header is absent or malformed.
func bearerFromRequest(r *http.Request) string {
const prefix = "Bearer "
h := r.Header.Get("Authorization")
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return ""
}
return strings.TrimSpace(h[len(prefix):])
}
@@ -0,0 +1,286 @@
package gateway
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// newRefreshEnv builds a gateway backed by a real JWT service and a real
// SQLite-backed refresh store, returning the store and DB so a test can assert
// what was persisted. The DB is isolated in memory per test.
func newRefreshEnv(t *testing.T) (*testEnv, *auth.RefreshStore, *sql.DB) {
t.Helper()
ctx := context.Background()
sqlDB, err := db.Open(ctx, ":memory:", nil)
if err != nil {
t.Fatalf("open in-memory db: %v", err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.MigrateUp(ctx, sqlDB, nil); err != nil {
t.Fatalf("migrate up: %v", err)
}
refresh, err := auth.NewRefreshStore(ctx, sqlDB, nil)
if err != nil {
t.Fatalf("new refresh store: %v", err)
}
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
b := bus.New()
cfg := &config.Config{Auth: config.AuthConfig{
AdminUser: "admin",
AdminPass: "secret",
JWTSecret: testSecret,
RefreshTTL: time.Hour,
}}
srv, err := New(cfg, Deps{Auth: jwt, Bus: b, Refresh: refresh})
if err != nil {
t.Fatalf("new server: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return &testEnv{srv: srv, ts: ts, auth: jwt, bus: b}, refresh, sqlDB
}
// decodeData unmarshals the {data: ...} envelope payload into v.
func decodeData(t *testing.T, body []byte, v any) {
t.Helper()
env := decodeBody(t, body)
raw, ok := env["data"]
if !ok {
t.Fatalf("response has no data envelope: %s", body)
}
if err := json.Unmarshal(raw, v); err != nil {
t.Fatalf("decode data %s: %v", body, err)
}
}
// loginRefresh performs an admin login and returns the access and refresh tokens.
func loginRefresh(t *testing.T, ts *httptest.Server) (string, string) {
t.Helper()
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/login",
map[string]string{"username": "admin", "password": "secret"}, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("login status = %d, want 200: %s", resp.StatusCode, body)
}
var data struct {
Token string `json:"token"`
RefreshToken string `json:"refreshToken"`
Role string `json:"role"`
ExpiresAt int64 `json:"expiresAt"`
}
decodeData(t, body, &data)
if data.Token == "" || data.ExpiresAt == 0 || data.Role != "owner" {
t.Fatalf("login payload = %+v, want token/expiresAt/role=owner", data)
}
if data.RefreshToken == "" {
t.Fatalf("login payload has no refreshToken: %s", body)
}
return data.Token, data.RefreshToken
}
// postRefresh calls /auth/refresh with token and returns the status and body.
func postRefresh(t *testing.T, ts *httptest.Server, token string) (int, []byte) {
t.Helper()
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/refresh",
map[string]string{"refreshToken": token}, "")
return resp.StatusCode, body
}
// wantInvalidGrant asserts a 401 with the machine-readable invalid_grant code.
func wantInvalidGrant(t *testing.T, status int, body []byte) {
t.Helper()
if status != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401: %s", status, body)
}
env := decodeBody(t, body)
var eobj struct {
Code string `json:"code"`
}
if err := json.Unmarshal(env["error"], &eobj); err != nil {
t.Fatalf("error envelope: %v (%s)", err, body)
}
if eobj.Code != "invalid_grant" {
t.Fatalf("error code = %q, want invalid_grant: %s", eobj.Code, body)
}
}
func TestLoginReturnsRefreshTokenHashedAtRest(t *testing.T) {
env, _, sqlDB := newRefreshEnv(t)
access, refresh := loginRefresh(t, env.ts)
if _, err := env.auth.Verify(access); err != nil {
t.Fatalf("access token does not verify: %v", err)
}
var hash string
if err := sqlDB.QueryRowContext(context.Background(),
"SELECT token_hash FROM refresh_tokens WHERE user_id = 'admin'").Scan(&hash); err != nil {
t.Fatalf("select token_hash: %v", err)
}
if hash == refresh {
t.Fatal("refresh token stored in plaintext")
}
sum := sha256.Sum256([]byte(refresh))
if want := hex.EncodeToString(sum[:]); hash != want {
t.Fatalf("stored hash = %q, want %q", hash, want)
}
}
func TestRefreshRotatesAccessAndRefreshTokens(t *testing.T) {
env, _, _ := newRefreshEnv(t)
_, oldRefresh := loginRefresh(t, env.ts)
status, body := postRefresh(t, env.ts, oldRefresh)
if status != http.StatusOK {
t.Fatalf("refresh status = %d, want 200: %s", status, body)
}
var data struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
Role string `json:"role"`
RefreshToken string `json:"refreshToken"`
}
decodeData(t, body, &data)
if data.Token == "" || data.ExpiresAt == 0 || data.Role != "owner" {
t.Fatalf("refresh payload = %+v, want token/expiresAt/role", data)
}
if data.RefreshToken == "" || data.RefreshToken == oldRefresh {
t.Fatalf("refresh token not rotated: %q", data.RefreshToken)
}
claims, err := env.auth.Verify(data.Token)
if err != nil {
t.Fatalf("refreshed access token does not verify: %v", err)
}
if claims.Sub != "admin" || claims.Role != auth.RoleOwner {
t.Fatalf("refreshed claims = %+v, want admin/owner", claims)
}
// The presented token is single-use: replay must fail.
status, body = postRefresh(t, env.ts, oldRefresh)
wantInvalidGrant(t, status, body)
// The rotated token works.
if status, body = postRefresh(t, env.ts, data.RefreshToken); status != http.StatusOK {
t.Fatalf("rotate replacement status = %d, want 200: %s", status, body)
}
}
func TestRefreshRejectsBadGrants(t *testing.T) {
env, refresh, sqlDB := newRefreshEnv(t)
ctx := context.Background()
expired := "expired-token-value"
sum := sha256.Sum256([]byte(expired))
if _, err := sqlDB.ExecContext(ctx,
`INSERT INTO refresh_tokens (id, user_id, role, token_hash, expires_at) VALUES (?, ?, ?, ?, ?)`,
"seed-expired", "admin", "owner", hex.EncodeToString(sum[:]),
time.Now().UTC().Add(-time.Minute).Format(time.RFC3339Nano)); err != nil {
t.Fatalf("seed expired token: %v", err)
}
revoked, _, err := refresh.Issue(ctx, "admin", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("issue revoked token: %v", err)
}
if ok, err := refresh.Revoke(ctx, revoked); err != nil || !ok {
t.Fatalf("revoke = (%v, %v), want (true, nil)", ok, err)
}
cases := map[string]string{
"empty": "",
"garbage": "not-a-real-token",
"expired": expired,
"revoked": revoked,
}
for name, token := range cases {
t.Run(name, func(t *testing.T) {
status, body := postRefresh(t, env.ts, token)
wantInvalidGrant(t, status, body)
})
}
}
func TestLogoutRevokesRefreshToken(t *testing.T) {
env, _, _ := newRefreshEnv(t)
_, refreshToken := loginRefresh(t, env.ts)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout",
map[string]string{"refreshToken": refreshToken}, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("logout status = %d, want 200: %s", resp.StatusCode, body)
}
var data struct {
Revoked bool `json:"revoked"`
}
decodeData(t, body, &data)
if !data.Revoked {
t.Fatalf("logout revoked = false, want true: %s", body)
}
if status, body := postRefresh(t, env.ts, refreshToken); status != http.StatusUnauthorized {
t.Fatalf("refresh after logout status = %d, want 401: %s", status, body)
}
// Logout is idempotent: a second call reports revoked=false, not an error.
resp, body = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout",
map[string]string{"refreshToken": refreshToken}, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("second logout status = %d, want 200: %s", resp.StatusCode, body)
}
decodeData(t, body, &data)
if data.Revoked {
t.Fatalf("second logout revoked = true, want false")
}
}
func TestLogoutWithAccessTokenRevokesUserGrants(t *testing.T) {
env, _, _ := newRefreshEnv(t)
access, refreshToken := loginRefresh(t, env.ts)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout", nil, access)
if resp.StatusCode != http.StatusOK {
t.Fatalf("logout status = %d, want 200: %s", resp.StatusCode, body)
}
var data struct {
Revoked bool `json:"revoked"`
RevokedCount int64 `json:"revokedCount"`
}
decodeData(t, body, &data)
if !data.Revoked || data.RevokedCount != 1 {
t.Fatalf("logout payload = %+v, want revoked with 1 grant", data)
}
if status, body := postRefresh(t, env.ts, refreshToken); status != http.StatusUnauthorized {
t.Fatalf("refresh after access-token logout status = %d, want 401: %s", status, body)
}
}
func TestRotateJWTInvalidatesRefreshTokens(t *testing.T) {
env, _, _ := newRefreshEnv(t)
_, refreshToken := loginRefresh(t, env.ts)
owner := env.token(t, auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/system/rotate-jwt",
map[string]string{"newSecret": "a-brand-new-jwt-secret-at-least-32-chars"}, owner)
if resp.StatusCode != http.StatusOK {
t.Fatalf("rotate-jwt status = %d, want 200: %s", resp.StatusCode, body)
}
var data struct {
Rotated bool `json:"rotated"`
RefreshTokensRevoked int64 `json:"refreshTokensRevoked"`
}
decodeData(t, body, &data)
if !data.Rotated || data.RefreshTokensRevoked != 1 {
t.Fatalf("rotate-jwt payload = %+v, want rotated with 1 refresh token revoked", data)
}
if status, body := postRefresh(t, env.ts, refreshToken); status != http.StatusUnauthorized {
t.Fatalf("refresh after rotate-jwt status = %d, want 401: %s", status, body)
}
}
+22 -6
View File
@@ -59,11 +59,14 @@ type loginRequest struct {
Password string `json:"password"`
}
// loginResponse is the login success payload.
// loginResponse is the login success payload. RefreshToken is the opaque
// long-lived credential (POST /auth/refresh) added by B2; it is omitted when no
// refresh store is wired so the pre-B2 contract is unchanged.
type loginResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
Role string `json:"role"`
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
Role string `json:"role"`
RefreshToken string `json:"refreshToken,omitempty"`
}
// chatRequest is the POST /api/v1/chat body. When Stream is true the gateway
@@ -114,11 +117,24 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrInternal, "login_failed", "could not sign token"))
return
}
writeJSON(w, http.StatusOK, loginResponse{
resp := loginResponse{
Token: token,
ExpiresAt: time.Now().Add(tokenTTL).Unix(),
Role: string(auth.RoleOwner),
})
}
// Issue the long-lived refresh credential alongside the access token. A store
// failure fails the login rather than silently handing back a login without
// the advertised refresh token; the plaintext is never logged.
if s.refresh != nil {
refreshToken, _, rerr := s.refresh.Issue(r.Context(), req.Username, auth.RoleOwner, s.refreshTTL())
if rerr != nil {
s.log.Error("login issue refresh token", "err", rerr)
writeError(w, apperr.Wrap(apperr.ErrInternal, "login_failed", "could not issue refresh token"))
return
}
resp.RefreshToken = refreshToken
}
writeJSON(w, http.StatusOK, resp)
}
// handleChat runs one synchronous agent turn (or triggers streaming).
+36
View File
@@ -157,6 +157,32 @@ type UserStore interface {
List() []auth.UserSummary
}
// RefreshGrantStore manages opaque refresh tokens (auth.RefreshStore is the
// production implementation). It is the credential behind the refreshToken
// returned by login and the POST /auth/refresh, /auth/logout endpoints. Only
// hashes are persisted; the plaintext token crosses this interface exactly once
// per issue/rotate. Optional: when nil login omits refreshToken and the refresh
// and logout endpoints report a clear not-configured error.
type RefreshGrantStore interface {
// Issue mints a refresh token for userID/role and returns its plaintext
// (visible once) plus its expiry.
Issue(ctx context.Context, userID string, role auth.Role, ttl time.Duration) (string, time.Time, error)
// Rotate validates plaintext and, when opts.Rotate is set, revokes it and
// returns a replacement. It returns auth.ErrInvalidGrant for an unknown,
// revoked, or expired token.
Rotate(ctx context.Context, plaintext string, opts auth.RotateOptions) (auth.RefreshToken, string, error)
// Revoke marks the token matching plaintext revoked, returning whether a
// live token was found. It is idempotent.
Revoke(ctx context.Context, plaintext string) (bool, error)
// RevokeAll deletes every refresh token (global invalidation on secret
// rotation) and returns how many were removed.
RevokeAll(ctx context.Context) (int64, error)
// RevokeByUser deletes every refresh token for userID (logout by access
// token, where the individual grant is unknown) and returns how many were
// removed.
RevokeByUser(ctx context.Context, userID string) (int64, error)
}
// BackthinkStore enumerates the persisted background-think records for the
// M9/M10 monitor panel. *backthink.Backthink is the production implementation.
type BackthinkStore interface {
@@ -247,6 +273,9 @@ type Deps struct {
// Users lists accounts and API keys. Optional (falls back to the configured
// single admin account).
Users UserStore
// Refresh is the opaque refresh-token store. Optional: when nil login omits
// refreshToken and /auth/refresh + /auth/logout answer not-configured.
Refresh RefreshGrantStore
// Backthink lists background-think records. Optional.
Backthink BackthinkStore
// RAG backs the admin knowledge-base RAG module. Optional; list/search
@@ -314,6 +343,7 @@ type Server struct {
taskOutputs TaskOutputStore
automations AutomationStore
users UserStore
refresh RefreshGrantStore
backthink BackthinkStore
rag RagStore
obs *observability
@@ -363,6 +393,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
taskOutputs: deps.Tasks,
automations: deps.Automations,
users: deps.Users,
refresh: deps.Refresh,
backthink: deps.Backthink,
rag: deps.RAG,
obs: newObservability(deps),
@@ -390,6 +421,11 @@ func (s *Server) Handler() http.Handler {
// Public, no auth.
mux.HandleFunc("GET /api/v1/health", s.handleHealth)
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
// Refresh/logout are public by design: the opaque refresh token is itself the
// credential, so a client with an expired access token can still rotate or
// revoke it. Both fail closed with 401 invalid_grant on a bad token.
mux.HandleFunc("POST /api/v1/auth/refresh", s.handleRefresh)
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
// User side, authenticated. Chat is a write (owner/admin); reads accept any
// authenticated role so a viewer can still list personas/sessions.