fix(api): a session-store outage answers 503, not 401

Resolving a session cookie failed identically whether the credential was
missing or Postgres was unreachable: local_auth_enabled read errors fell into
the fail-closed 'disabled' branch and SessionUser errors into 'invalid
session', both surfacing as 401 'authentication required' — a lie that reads
as 'log in again' during an outage. Split the enabled-read into
(enabled, error), tag non-ErrNotFound store failures with errAuthBackend, and
map that to a new 503 auth_unavailable in requireExternal. Fail-closed is
unchanged: missing setting / bad value / missing session stay 401.
This commit is contained in:
Lemon-miaow committed 2026-09-22 20:30:43 +08:00
1 parent abce381faa
commit 2a8f897e61
4 files changed
+93 -8

No files matched your search

+35 -6
View File
@@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
@@ -145,14 +146,24 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
}
ctx := r.Context()
if !localAuthEnabled(ctx, s.Repo) {
enabled, err := localAuthEnabledStatus(ctx, s.Repo)
if err != nil {
// The session store is unreachable: this is an outage, not a verdict on
// the caller's credentials, so the middleware answers 503 rather than a
// misleading "please log in".
return nil, fmt.Errorf("%w: %v", errAuthBackend, err)
}
if !enabled {
// A cookie was presented but local auth is off: reject, never fall through.
return nil, fmt.Errorf("local auth disabled")
}
u, err := s.Repo.SessionUser(ctx, hashCookie(cookie.Value), s.now())
if err != nil {
switch {
case errors.Is(err, ErrNotFound):
return nil, fmt.Errorf("invalid session: %w", err)
case err != nil:
return nil, fmt.Errorf("%w: %v", errAuthBackend, err)
}
return &Principal{
UserID: u.ID,
@@ -164,6 +175,11 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
}, nil
}
// errAuthBackend marks an authentication failure caused by the session store
// being unreachable (e.g. Postgres down) rather than by a missing or invalid
// credential. Middleware maps it to 503 so an outage is not misreported as 401.
var errAuthBackend = errors.New("auth backend unavailable")
// localAuthEnabled reports whether the runtime local_auth_enabled toggle is true.
// A missing setting, a read error, or a non-true value all read as disabled — the
// gate fails closed so local sessions are honored, and new ones minted, only on an
@@ -171,15 +187,28 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
// (minting one) consult it, so the two never disagree about whether local auth is
// live.
func localAuthEnabled(ctx context.Context, repo Repo) bool {
enabled, _ := localAuthEnabledStatus(ctx, repo)
return enabled
}
// localAuthEnabledStatus is localAuthEnabled with the outage case kept apart: a
// MISSING setting (ErrNotFound — never enabled) reads as (false, nil), while a
// store read failure reads as (false, err) so SessionAuth can tell "local auth
// is off" (401) from "the database is down" (503). An unreadable value still
// fails closed as disabled — it is a config fault, not an outage.
func localAuthEnabledStatus(ctx context.Context, repo Repo) (bool, error) {
raw, err := repo.GetSetting(ctx, LocalAuthEnabledKey)
if err != nil {
return false // ErrNotFound (never enabled) or a transient read error → closed
switch {
case errors.Is(err, ErrNotFound):
return false, nil
case err != nil:
return false, err
}
var enabled bool
if err := json.Unmarshal(raw, &enabled); err != nil {
return false
return false, nil
}
return enabled
return enabled, nil
}
// ensure SessionAuth satisfies ExternalAuth at compile time.