fix(api): 删除未接通的 Access JWT 委托,外部面只认会话 cookie,admin 主机的 IP 判定只认安装指定的地址,文档与 OpenAPI 同步

This commit is contained in:
Lemon-miaow committed 2026-09-25 15:35:26 +08:00
1 parent a883c1fe07
commit 38288e1c60
15 files changed
+274 -382

No files matched your search

+10 -9
View File
@@ -1,9 +1,10 @@
// Package api implements felis-api: one binary serving two faces (spec §7).
//
// The internal face (velocity / backend callbacks) authenticates with a static
// service token and is never wrapped in Zero Trust. The external face (people /
// panel) authenticates with a Cloudflare Access JWT; admin-tier operations
// additionally require the admin Access path (spec §14, graded by operation).
// per-caller service token and is never wrapped in Zero Trust. The external face
// (people / panel) authenticates the local session cookie; admin-tier operations
// additionally require a staff session on the operator console host (spec §14,
// graded by operation).
//
// Handlers depend on the Repo and Cluster interfaces, so the request routing,
// dual-face auth, input validation and authorization are all unit-tested with
@@ -303,7 +304,7 @@ func (a *API) streamGate() *streamLimiter {
}
// streamKey identifies the principal a stream slot is charged to. It prefers the
// stable user id and falls back to the email so a JWT principal without a user id is
// stable user id and falls back to the email so a principal without a user id is
// still bucketed by identity; an empty key (no authenticated identity, which the
// external face's auth guard already precludes) shares one bucket, which is safe
// because it is more restrictive, never less.
@@ -443,8 +444,8 @@ func (a *API) internalAPIRoutes() []apiRoute {
}
// externalAPIRoutes is the external face's served route table (spec §7, §14):
// Cloudflare Access-JWT auth on every /api/v1 route; the Admin entries are
// additionally gated on the admin Zero-Trust path. It exposes liveness only —
// session auth on every non-public /api/v1 route; the Admin entries are
// additionally gated on the operator console host. It exposes liveness only —
// readiness is an internal concern.
func (a *API) externalAPIRoutes() []apiRoute {
return []apiRoute{
@@ -691,9 +692,9 @@ func (a *API) InternalHandler() http.Handler {
return a.buildFace("internal", a.internalAPIRoutes(), a.requireInternal)
}
// ExternalHandler builds the external-face http.Handler: Access-JWT auth on every
// /api/v1 route, with admin-tier routes additionally gated by the admin Access
// path inside their handlers.
// ExternalHandler builds the external-face http.Handler: session auth on every
// non-public /api/v1 route, with admin-tier routes additionally gated on the
// operator console host inside their handlers.
func (a *API) ExternalHandler() http.Handler {
return a.buildFace("external", a.externalAPIRoutes(), a.requireExternal)
}
+50 -73
View File
@@ -15,7 +15,6 @@ import (
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"github.com/golang-jwt/jwt/v5"
)
const testRoot = "mc.example.net" // neutral; never a deployment domain
@@ -1743,8 +1742,8 @@ func (f *fakeConsole) RunCommand(_ context.Context, name, command string) (strin
return f.reply, nil
}
// staticExternal injects a fixed principal so handler logic is tested without
// real JWT crypto (which is exercised separately in TestAccessVerifier).
// staticExternal injects a fixed principal so handler logic is tested without a
// session store.
type staticExternal struct {
p *Principal
err error
@@ -2600,8 +2599,6 @@ func TestErrorEnvelopeHasRequestID(t *testing.T) {
}
}
// ---- real AccessVerifier (JWT aud) ----
func TestSessionAuthUsesConfiguredAdminHostname(t *testing.T) {
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
@@ -2630,85 +2627,65 @@ func TestSessionAuthUsesConfiguredAdminHostname(t *testing.T) {
t.Fatalf("root-domain fallback host must not grant admin-path access when admin_hostname is configured")
}
// A private address the install never named is the player face, whatever the
// Host header claims.
r = httptest.NewRequest("GET", "https://10.211.55.4:30443/api/v1/me", nil)
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
p, err = auth.Authenticate(r)
if err != nil {
t.Fatalf("Authenticate private IP host: %v", err)
}
if !p.ViaAdminAccess {
t.Fatalf("private IP local panel should grant admin-path access, got %+v", p)
if p.ViaAdminAccess {
t.Fatalf("an unnamed private IP must not grant admin-path access, got %+v", p)
}
}
func TestAccessVerifier(t *testing.T) {
key := []byte("test-signing-key")
keyfunc := func(*jwt.Token) (any, error) { return key, nil }
v := AccessVerifier{Audience: "felis-app", AdminAudience: "felis-admin", Keyfunc: keyfunc}
sign := func(claims accessClaims) string {
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, err := tok.SignedString(key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
// The operator console by Host: the configured name, the op.console.<root>
// fallback, and a bare IP only where the install names it (the nip.io/sslip.io
// root domain's embedded address, or an IP admin_hostname).
func TestHostIsAdminConsole(t *testing.T) {
cases := []struct {
name, host, root, admin string
want bool
}{
{"configured name", "op.console.mc.example.net", "mc.example.net", "op.console.mc.example.net", true},
{"configured name with port and case", "OP.Console.mc.example.net:30443", "mc.example.net", "op.console.mc.example.net", true},
{"fallback name", "op.console.mc.example.net", "mc.example.net", "", true},
{"player console", "console.mc.example.net", "mc.example.net", "", false},
{"nip.io embedded IP", "10.211.55.6:30443", "10.211.55.6.nip.io", "op.console.10.211.55.6.nip.io", true},
{"sslip.io embedded IP", "192.168.1.20", "192.168.1.20.sslip.io", "", true},
{"other private IP on a nip.io install", "10.211.55.7:30443", "10.211.55.6.nip.io", "", false},
{"loopback on a nip.io install", "127.0.0.1:30443", "10.211.55.6.nip.io", "", false},
{"loopback on a named domain", "127.0.0.1:30443", "mc.example.net", "", false},
{"private IP on a named domain", "10.0.0.5", "mc.example.net", "", false},
{"IPv6 ULA on a named domain", "[fd00::5]:30443", "mc.example.net", "", false},
{"admin_hostname set to an IP", "10.0.0.5:30443", "mc.example.net", "10.0.0.5", true},
{"admin_hostname IPv6", "[fd00::5]:30443", "mc.example.net", "fd00::5", true},
{"admin_hostname IPv6 on the default port", "[fd00::5]", "mc.example.net", "fd00::5", true},
{"another IP than admin_hostname's", "10.0.0.6", "mc.example.net", "10.0.0.5", false},
{"no domain configured", "10.0.0.5", "", "", false},
}
exp := jwt.NewNumericDate(time.Now().Add(time.Hour))
for _, tc := range cases {
r := httptest.NewRequest("GET", "/api/v1/me", nil)
r.Host = tc.host
if got := hostIsAdminConsole(r, tc.root, tc.admin); got != tc.want {
t.Errorf("%s: Host %q root %q admin %q = %v, want %v", tc.name, tc.host, tc.root, tc.admin, got, tc.want)
}
}
}
t.Run("valid app token", func(t *testing.T) {
s := sign(accessClaims{Email: "[email protected]", RegisteredClaims: jwt.RegisteredClaims{
Subject: "u1", Audience: jwt.ClaimStrings{"felis-app"}, ExpiresAt: exp}})
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", "Bearer "+s)
p, err := v.Authenticate(r)
if err != nil {
t.Fatalf("authenticate: %v", err)
}
if p.UserID != "u1" || p.Email != "[email protected]" || p.Role != "user" || p.ViaAdminAccess {
t.Fatalf("unexpected principal %+v", p)
}
})
t.Run("admin audience sets ViaAdminAccess", func(t *testing.T) {
s := sign(accessClaims{Role: "admin", RegisteredClaims: jwt.RegisteredClaims{
Subject: "a1", Audience: jwt.ClaimStrings{"felis-app", "felis-admin"}, ExpiresAt: exp}})
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Cf-Access-Jwt-Assertion", s)
p, err := v.Authenticate(r)
if err != nil {
t.Fatalf("authenticate: %v", err)
}
if !p.IsAdmin() {
t.Fatalf("expected admin principal, got %+v", p)
}
})
t.Run("wrong audience rejected", func(t *testing.T) {
s := sign(accessClaims{RegisteredClaims: jwt.RegisteredClaims{
Subject: "u1", Audience: jwt.ClaimStrings{"someone-else"}, ExpiresAt: exp}})
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", "Bearer "+s)
if _, err := v.Authenticate(r); err == nil {
t.Fatal("expected audience rejection")
}
})
t.Run("wrong signing key rejected", func(t *testing.T) {
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims{RegisteredClaims: jwt.RegisteredClaims{
Subject: "u1", Audience: jwt.ClaimStrings{"felis-app"}, ExpiresAt: exp}})
s, _ := tok.SignedString([]byte("attacker-key"))
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", "Bearer "+s)
if _, err := v.Authenticate(r); err == nil {
t.Fatal("expected signature rejection")
}
})
t.Run("missing expiry rejected", func(t *testing.T) {
s := sign(accessClaims{RegisteredClaims: jwt.RegisteredClaims{
Subject: "u1", Audience: jwt.ClaimStrings{"felis-app"}}})
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", "Bearer "+s)
if _, err := v.Authenticate(r); err == nil {
t.Fatal("expected missing-expiry rejection")
}
})
// With no session cookie there is nothing to authenticate: a Cloudflare Access
// assertion or a bearer JWT is not a credential felis-api accepts.
func TestSessionAuthIgnoresAccessAssertions(t *testing.T) {
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
auth := SessionAuth{Repo: repo, RootDomain: "mc.example.net"}
r := httptest.NewRequest("GET", "https://op.console.mc.example.net/api/v1/me", nil)
r.Header.Set("Cf-Access-Jwt-Assertion", "eyJhbGciOiJSUzI1NiJ9.eyJmZWxpc19yb2xlIjoib3duZXIifQ.sig")
r.Header.Set("Authorization", "Bearer eyJhbGciOiJSUzI1NiJ9.eyJmZWxpc19yb2xlIjoib3duZXIifQ.sig")
if p, err := auth.Authenticate(r); err == nil {
t.Fatalf("authenticated %+v without a session", p)
}
}
// TestSessionAuthOutageIs503Not401: a session-store outage must surface as 503
+2 -2
View File
@@ -36,8 +36,8 @@ const (
)
// auditActor is the display name for a principal: an email only when something
// vouches for it (an Access JWT, or a session whose address was verified), else
// the username. A player can set their address to anyone's before verifying it,
// vouches for it (a session whose address was verified, or an ExternalAuth other
// than SessionAuth that resolved the principal itself), else the username. A player can set their address to anyone's before verifying it,
// so an unverified email would let them sign rows as that person.
func auditActor(p *Principal) string {
switch {
+19 -101
View File
@@ -6,29 +6,25 @@ import (
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Principal is the authenticated external-face caller (spec §7, §14). The
// internal face (service token) never produces a Principal — it is a trusted
// machine caller, not a person.
type Principal struct {
// UserID is the stable web identity (SSO subject → users.id).
// UserID is the account's users.id.
UserID string
// Username is the account's login name; empty for an Access-JWT caller.
// Username is the account's login name.
Username string
// Email is the account's address. Only an Access JWT or EmailVerified vouches
// for it: a player can set any address before verifying it (auditActor).
// Email is the account's address. Only EmailVerified vouches for it: a player
// can set any address before verifying it (auditActor).
Email string
// Role is "owner", "admin", or "user" (mirrors users.role).
Role string
// ViaAdminAccess is true only when the request arrived through an admin-graded
// path: the admin.* Zero-Trust hostname (Cloudflare Access, the remote face) OR
// a local session presented on the op.console host (SessionAuth, the
// passwordless face). Admin-tier operations require it in addition to
// a staff role (spec §14: ZT is graded by operation). A staff session
// arriving on the player console (console.*) never sets it.
// ViaAdminAccess is true only when a staff session arrived on the operator
// console host (hostIsAdminConsole). Admin-tier operations require it in
// addition to a staff role (spec §14: ZT is graded by operation). A staff
// session arriving on the player console (console.*) never sets it.
ViaAdminAccess bool
// EmailVerified mirrors users.email_verified. The lockdown middleware gates
// setup-incomplete accounts (EmailVerified=false, e.g. a freshly bootstrapped
@@ -36,14 +32,12 @@ type Principal struct {
// routes only, so an intercepted setup URL cannot yield full admin access
// before the email-OTP verification step completes.
EmailVerified bool
// ViaSession is true when the principal was authenticated via a local session
// cookie (SessionAuth), not a Cloudflare-Access JWT. The setup-lockdown gate
// only applies to session-authenticated principals — a JWT caller already
// passed Zero Trust at the edge, so the local-email-verification gate is not
// the right boundary for them.
// ViaSession is true for every principal SessionAuth resolves from a session
// cookie. The session-scoped gates (setup lockdown, reauth, the device list)
// key on it.
ViaSession bool
// ReauthAt is when the holder of the session last proved a factor of the
// account; zero for a session that never did and for a JWT caller.
// account; zero for a session that never did.
ReauthAt time.Time
}
@@ -56,8 +50,8 @@ func staffRole(role string) bool {
}
// IsAdmin reports whether the principal may perform admin-tier operations.
// Both the role claim and the admin Access path are required: a staff
// session arriving on panel.* must not bypass the Zero-Trust boundary.
// Both the staff role and arrival on the operator console host are required: a
// staff session arriving on the player console must not reach admin routes.
// An owner implicitly passes this check (the owner role is a superset of admin).
func (p *Principal) IsAdmin() bool {
return p != nil && staffRole(p.Role) && p.ViaAdminAccess
@@ -66,7 +60,7 @@ func (p *Principal) IsAdmin() bool {
// IsOwner reports whether the principal holds the platform-level owner role
// — the single identity that may manage users, quotas, and sessions. Only the
// first staff account minted by break-glass carries this role; every subsequent
// Operator is a plain admin. Like IsAdmin, it requires the admin Access path.
// Operator is a plain admin. Like IsAdmin, it requires the operator console host.
func (p *Principal) IsOwner() bool {
return p != nil && p.Role == "owner" && p.ViaAdminAccess
}
@@ -101,9 +95,10 @@ type InternalAuth interface {
}
// ExternalAuth authenticates the external face (people / panel) and returns the
// resolved Principal. Production verifies a Cloudflare Access JWT and checks its
// audience; the verification key source (JWKS) is injected so the audience and
// expiry logic stay unit-testable.
// resolved Principal. Production is SessionAuth: the local session cookie the
// sign-in doors mint. Cloudflare Access, when an install sits behind it, is
// enforced at the edge only; felis-api does not read the Access JWT, so the
// identity and role always come from the users table.
type ExternalAuth interface {
Authenticate(r *http.Request) (*Principal, error)
}
@@ -149,64 +144,6 @@ func (c CallerTokens) Authenticate(r *http.Request) (Caller, error) {
return match, nil
}
// AccessVerifier is the production ExternalAuth: it parses a Cloudflare Access
// JWT, verifies the signature with the injected key function, and enforces the
// configured audience (spec §7 "验 aud"). AdminAudience, when set, marks a token
// minted for the admin.* application so admin-tier routes can require it.
type AccessVerifier struct {
// Audience is the required `aud` claim for any external request.
Audience string
// AdminAudience, if non-empty and present in the token's aud set, flags the
// principal as having passed the admin Zero-Trust path.
AdminAudience string
// Keyfunc resolves the signing key (production: a JWKS-backed keyfunc).
Keyfunc jwt.Keyfunc
}
// accessClaims are the subset of Access JWT claims we consume.
type accessClaims struct {
Email string `json:"email"`
Role string `json:"felis_role"`
jwt.RegisteredClaims
}
// Authenticate verifies the Access JWT and maps it onto a Principal.
func (v AccessVerifier) Authenticate(r *http.Request) (*Principal, error) {
if v.Keyfunc == nil {
return nil, fmt.Errorf("external auth not configured")
}
raw := accessToken(r)
if raw == "" {
return nil, fmt.Errorf("missing access token")
}
var claims accessClaims
parser := jwt.NewParser(jwt.WithExpirationRequired())
if _, err := parser.ParseWithClaims(raw, &claims, v.Keyfunc); err != nil {
return nil, fmt.Errorf("invalid access token: %w", err)
}
// Audience check: the configured app aud must be present. We do not delegate
// to jwt.WithAudience so we can additionally detect the admin audience.
if !audienceContains(claims.Audience, v.Audience) {
return nil, fmt.Errorf("token audience does not include %q", v.Audience)
}
if claims.Subject == "" {
return nil, fmt.Errorf("token missing subject")
}
role := claims.Role
if role == "" {
role = "user"
}
return &Principal{
UserID: claims.Subject,
Email: claims.Email,
Role: role,
ViaAdminAccess: v.AdminAudience != "" && audienceContains(claims.Audience, v.AdminAudience),
}, nil
}
// bearerToken extracts a Bearer credential from the Authorization header.
func bearerToken(r *http.Request) string {
const prefix = "Bearer "
@@ -216,22 +153,3 @@ func bearerToken(r *http.Request) string {
}
return ""
}
// accessToken prefers the Cloudflare Access assertion header, falling back to a
// Bearer credential so the same verifier works behind a proxy or directly.
func accessToken(r *http.Request) string {
if h := r.Header.Get("Cf-Access-Jwt-Assertion"); h != "" {
return h
}
return bearerToken(r)
}
// audienceContains reports whether want appears in the aud claim set.
func audienceContains(aud jwt.ClaimStrings, want string) bool {
for _, a := range aud {
if a == want {
return true
}
}
return false
}
+2 -2
View File
@@ -92,8 +92,8 @@ func (a *API) revokeOtherSessionsAfter(r *http.Request, change string) {
}
}
// callerSessionHash is the session the request authenticated with, or "" when it
// authenticated some other way (a cookie beside an Access JWT names nothing).
// callerSessionHash is the session the request authenticated with, or "" for a
// principal that did not come from a session cookie.
func callerSessionHash(r *http.Request, p *Principal) string {
if p == nil || !p.ViaSession {
return ""
+1 -1
View File
@@ -543,7 +543,7 @@ func TestOpLoginGates(t *testing.T) {
// TestOpLoginFaceSeparation enforces the two-face split: the three public browser legs
// must 404 on the internal (service-token) face, and the two internal in-game legs must
// 404 on the external (Access-JWT) face.
// 404 on the external (session) face.
func TestOpLoginFaceSeparation(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
+2 -2
View File
@@ -239,8 +239,8 @@ func callersOnly(callers []Caller, next http.HandlerFunc) http.HandlerFunc {
}
}
// requireExternal enforces Access-JWT auth for the external face and stashes the
// resolved Principal in the request context.
// requireExternal authenticates the external face (SessionAuth in production)
// and stashes the resolved Principal in the request context.
func (a *API) requireExternal(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, err := a.External.Authenticate(r)
+2 -2
View File
@@ -782,8 +782,8 @@ func (p *PGRepo) Audit(ctx context.Context, e AuditEntry) error {
if len(e.Payload) > 0 {
payload = string(e.Payload)
}
// actor_user_id goes through a lookup so an id with no users row (an
// Access-JWT subject, a purged account) lands as NULL instead of failing
// actor_user_id goes through a lookup so an id with no users row (a purged
// account) lands as NULL instead of failing
// the foreign key and losing the row.
_, err := p.db.ExecContext(ctx,
`INSERT INTO audit_logs (actor, source, action, server_name, request_id, payload,
+41 -24
View File
@@ -12,14 +12,14 @@ import (
"log"
"net"
"net/http"
"net/netip"
"strings"
"time"
)
// Local sessions (spec §B, passwordless). The remote face authenticates statelessly
// with a Cloudflare-Access JWT and sets no cookie; the passwordless console login
// (email-OTP / passkey / setup redeem), used on op.console when Zero Trust is not
// configured (and as the demo's primary web login), needs a server-minted session.
// Local sessions (spec §B, passwordless). Every sign-in door (email-OTP, passkey,
// bind code, op-login, setup redeem) ends in a server-minted session, the external
// face's only credential.
// We store only the sha-256 of the opaque cookie value, mirroring how service tokens
// are stored, so a database read never yields a usable cookie.
@@ -154,8 +154,13 @@ func clearSessionCookie(w http.ResponseWriter) {
// operator console host. The session cookie is host-only, so a session minted on
// the admin host is structurally unable to reach the player console. If older
// configs omit [auth].admin_hostname, fall back to op.console.<root_domain>.
// Local bootstrap may also use the node's private/loopback IP directly when
// wildcard DNS is unavailable; that is treated as the local admin face.
//
// A bare IP counts only when the install names it: the address a
// <ip>.nip.io / <ip>.sslip.io root domain embeds (what `felis setup` prints as
// the local panel URL when wildcard DNS is unavailable), or an admin_hostname
// set to an IP. The Host header is the client's to choose, so "any loopback or
// private address" would let anyone who reaches the origin's port present
// Host: 10.0.0.1 and be graded as the operator console.
func hostIsAdminConsole(r *http.Request, rootDomain, adminHostname string) bool {
want := strings.TrimSpace(adminHostname)
if want == "" {
@@ -168,25 +173,41 @@ func hostIsAdminConsole(r *http.Request, rootDomain, adminHostname string) bool
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil {
return ip.IsLoopback() || ip.IsPrivate()
if ip, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil {
ip = ip.Unmap()
if named, err := netip.ParseAddr(strings.Trim(want, "[]")); err == nil && named.Unmap() == ip {
return true
}
embedded, ok := rootDomainIP(rootDomain)
return ok && embedded == ip
}
return strings.EqualFold(strings.TrimSuffix(host, "."), strings.TrimSuffix(want, "."))
}
// SessionAuth is the composite ExternalAuth for the web face. It prefers a
// local session cookie and otherwise delegates to the remote JWT
// verifier, so both auth models coexist on one face:
// rootDomainIP is the address a wildcard-DNS root domain spells out:
// 10.0.0.5.nip.io and 10.0.0.5.sslip.io both name 10.0.0.5.
func rootDomainIP(rootDomain string) (netip.Addr, bool) {
domain := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(rootDomain), "."))
for _, suffix := range []string{".nip.io", ".sslip.io"} {
if base, ok := strings.CutSuffix(domain, suffix); ok {
if ip, err := netip.ParseAddr(base); err == nil {
return ip.Unmap(), true
}
}
}
return netip.Addr{}, false
}
// SessionAuth is the ExternalAuth for the web face: the local session cookie
// the sign-in doors mint. There is no other credential; Cloudflare Access, when
// the install sits behind it, is enforced at the edge.
//
// - No cookie → delegate to Delegate (the Cloudflare-Access JWT path).
// - No cookie → unauthenticated.
// - Cookie set → local auth MUST be enabled (a missing or non-true
// local_auth_enabled setting is treated as disabled — fail closed); the
// session hash must resolve to a live user. On any failure the request is
// rejected and does NOT fall through to the JWT delegate, so a stale or
// forged cookie can never be laundered into a JWT attempt.
// session hash must resolve to a live user.
type SessionAuth struct {
Repo Repo
Delegate ExternalAuth
RootDomain string
AdminHostname string
Now func() time.Time
@@ -199,16 +220,12 @@ func (s SessionAuth) now() time.Time {
return time.Now()
}
// Authenticate resolves the caller from a session cookie or delegates to the JWT
// verifier (see the type comment for the fail-closed rules).
// Authenticate resolves the caller from the session cookie (see the type
// comment for the fail-closed rules).
func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
// No usable session cookie: this is the remote JWT path.
if s.Delegate == nil {
return nil, fmt.Errorf("external auth not configured")
}
return s.Delegate.Authenticate(r)
return nil, fmt.Errorf("no session")
}
ctx := r.Context()
@@ -220,7 +237,7 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
return nil, fmt.Errorf("%w: %v", errAuthBackend, err)
}
if !enabled {
// A cookie was presented but local auth is off: reject, never fall through.
// A cookie was presented but local auth is off: reject.
return nil, fmt.Errorf("local auth disabled")
}
+9 -5
View File
@@ -2,9 +2,13 @@
// configuration the felis breakGlass TUI can offer a SysAdmin (spec §14 Zero
// Trust edge). It is deliberately "锦上添花" — icing, not a mandate: the platform
// is domain-agnostic (every FQDN is composed from the configured root_domain) and
// IdP-agnostic (felis-api validates ANY valid Cloudflare Access JWT `aud`, no
// matter which identity provider — Google Workspace, Keycloak, Microsoft Entra —
// fronts it). A SysAdmin who brings their own domain or a different Zero-Trust
// IdP-agnostic (Access is enforced at the Cloudflare edge, whichever identity
// provider — Google Workspace, Keycloak, Microsoft Entra — fronts it). felis-api
// does not read the Access JWT: behind the edge a caller still signs in with a
// local session, and its account and role come from the users table. The
// application's `aud` is recorded in [auth] access_jwt_aud as the marker that
// the install sits behind Cloudflare (it makes CF-Connecting-IP the client
// address). A SysAdmin who brings their own domain or a different Zero-Trust
// scheme is fully supported; this package only makes the common case easy.
//
// The split is honest about what this box can verify:
@@ -362,8 +366,8 @@ type Params struct {
OnProgress func(string) // optional, called at each step for TUI display
}
// Result reports what Setup produced, including the Access `aud` the caller must
// write into felis [auth] access_jwt_aud to make felis-api accept the new edge.
// Result reports what Setup produced, including the Access `aud` the caller
// records in felis [auth] access_jwt_aud (the behind-Cloudflare marker).
type Result struct {
TunnelID string
CredentialsFile string
+4 -2
View File
@@ -114,8 +114,10 @@ type VelocityConfig struct {
GamePort int `toml:"game_port"`
}
// AuthConfig is the [auth] table: the two privileged faces and the access-JWT
// audience the API enforces.
// AuthConfig is the [auth] table: the two privileged faces and the Cloudflare
// Access application's audience. The API does not verify Access JWTs (Access is
// enforced at the edge); a set audience marks the install as sitting behind
// Cloudflare, which makes CF-Connecting-IP the client address.
type AuthConfig struct {
AdminHostname string `toml:"admin_hostname"`
PanelHostname string `toml:"panel_hostname"`