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

+5 -13
View File
@@ -60,10 +60,8 @@ func authSourcesFromConfig(configured []config.AuthSourceConfig) []api.AuthSourc
}
// cmdAPI runs felis-api: two listeners, two middleware chains (spec §7). The
// internal face (service token) is fully wired. The external face is wired but
// fails closed until an Access JWKS key function is configured — the verifier's
// audience logic is unit-tested (internal/api), the JWKS source is a deployment
// integration point.
// internal face authenticates per-caller service tokens; the external face
// authenticates the local session cookie.
func cmdAPI(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("api", flag.ContinueOnError)
fs.SetOutput(stderr)
@@ -317,16 +315,11 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
Files: files,
Submissions: submissions,
Mailer: mailer,
// The external face is fronted by SessionAuth: it prefers a local session
// cookie (minted by the passwordless doors) and otherwise delegates to the
// Cloudflare-Access JWT verifier, so both auth models coexist on one face. The
// delegate's Keyfunc is intentionally nil — the JWT path fails closed until a
// JWKS-backed key function is wired (deployment integration point) — while the
// local session path is live the moment `felis breakGlass` flips
// local_auth_enabled on.
// The external face authenticates the local session cookie the sign-in doors
// mint, live once `felis breakGlass` flips local_auth_enabled on. Cloudflare
// Access, when the install sits behind it, is enforced at the edge only.
External: api.SessionAuth{
Repo: repo,
Delegate: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
RootDomain: cfg.Server.RootDomain,
AdminHostname: cfg.Auth.AdminHostname,
},
@@ -355,7 +348,6 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
ClientIPHeader: cfg.Auth.EffectiveClientIPHeader(),
MailLimit: mailLimit(cfg.SMTP.MaxPerHour),
}
fmt.Fprintln(stderr, "felis api: external face fails closed (Access JWKS key function not configured)")
if a.ClientIPHeader != "" {
fmt.Fprintf(stderr, "felis api: sign-in rate limit keys on the %s header\n", a.ClientIPHeader)
} else {
+103 -109
View File
@@ -26,10 +26,11 @@ info:
version: 4.1.0
description: |
Control plane for the Felis Minecraft orchestration platform. The same binary
exposes an internal face (service-token auth, for velocity / backend callbacks,
never Zero Trust) and an external face (Cloudflare Access JWT auth, for people
and the panel). Admin-tier external operations additionally require the admin
Access path. See `x-felis-face` / `x-felis-tier` on each operation.
exposes an internal face (per-caller service tokens, for velocity / backend
callbacks, never Zero Trust) and an external face (the felis_session cookie, for
people and the panel; Cloudflare Access, when present, is enforced at the edge).
Admin-tier external operations additionally require a staff session on the
operator console host. See `x-felis-face` / `x-felis-tier` on each operation.
Behaviour every operation shares, and so not repeated under each:
@@ -63,8 +64,9 @@ servers:
default: example.test
- url: https://api.{root_domain}
description: >-
External face. Cloudflare Access JWT auth on every /api/v1 route; admin-tier
routes additionally require the admin Access path.
External face. Session-cookie auth on every non-public /api/v1 route;
admin-tier routes additionally require a staff session on the operator
console host.
variables:
root_domain:
default: example.test
@@ -105,14 +107,6 @@ components:
callers it serves in x-felis-callers. A genuine token for a caller the
operation does not list is refused with 403 wrong_caller. `felis
rotate-token <caller>` replaces one.
accessJWT:
type: apiKey
in: header
name: Cf-Access-Jwt-Assertion
description: >-
Cloudflare Access JWT (external face). Admin-tier operations require the
token to have traversed the admin Access path; the handler additionally
asserts Principal.IsAdmin().
sessionCookie:
type: apiKey
in: cookie
@@ -122,8 +116,10 @@ components:
session doors — passkey login, email-OTP, bind code, and op-login
finish — HttpOnly+Secure+SameSite=Lax and host-only, so an op.console
session never reaches the player console. Only its sha-256 is
persisted. SessionAuth prefers this cookie and otherwise delegates to
accessJWT, so the two models coexist on one face.
persisted. It is the external face's only credential: Cloudflare
Access, when the install sits behind it, is enforced at the edge and
felis-api does not read the Access JWT. Admin-tier operations
additionally require a staff session on the operator console host.
responses:
NoContent:
@@ -785,14 +781,14 @@ paths:
operationId: createServer
summary: Create a server (admin).
description: >-
Requires the admin Access path; the image must be whitelisted. An image in the
Requires a staff session on the operator console host; the image must be whitelisted. An image in the
platform registry is stored pinned to the digest its tag names at creation
(name:tag@sha256:…), so a later push over the tag never moves the server;
400 image_not_in_registry when the registry lacks the tag, 503
registry_unavailable when it cannot be asked.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -1444,7 +1440,7 @@ paths:
summary: Wake your own server.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
responses:
@@ -1482,7 +1478,7 @@ paths:
summary: Stop your own server.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
responses:
@@ -1510,7 +1506,7 @@ paths:
summary: Claim an unowned server for your linked account.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
responses:
@@ -1552,7 +1548,7 @@ paths:
description: The RCON password is never accepted or returned (spec §286).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
requestBody:
@@ -1598,7 +1594,7 @@ paths:
summary: Stream the running pod's log over SSE (spec §8 read, §262). Owner/admin only.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: Last-Event-ID
@@ -1637,7 +1633,7 @@ paths:
(spec §286).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
responses:
@@ -1676,7 +1672,7 @@ paths:
(spec §286).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
requestBody:
@@ -1720,7 +1716,7 @@ paths:
The RCON password is never accepted or returned (spec §286).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
responses:
@@ -1762,7 +1758,7 @@ paths:
(spec §286).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
responses:
@@ -1800,7 +1796,7 @@ paths:
records intent). The RCON password is never accepted or returned (§286).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
requestBody:
@@ -1844,7 +1840,7 @@ paths:
never accepted or returned (spec §286).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
requestBody:
@@ -1897,7 +1893,7 @@ paths:
(spec §286).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
requestBody:
@@ -1943,7 +1939,7 @@ paths:
password is never accepted or returned (spec §286).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
requestBody:
@@ -1989,7 +1985,7 @@ paths:
is echoed back for anything the parser cannot represent.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- { name: player, in: path, required: true, schema: { type: string } }
@@ -2043,7 +2039,7 @@ paths:
displayName, phase, ready, playersOnline and playersMax.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
responses:
@@ -2870,14 +2866,13 @@ paths:
summary: The caller's own identity and tier (drives panel navigation).
description: >-
Returns the authenticated principal's user id, email, role and the
server-computed is_admin (Principal.IsAdmin(): role admin reached via the
admin Access path). The panel reads this once at boot to decide which
server-computed is_admin (Principal.IsAdmin(): role admin reached on the operator console host). The panel reads this once at boot to decide which
surfaces to render. It is UX truth, not a security control — admin routes
are independently gated server-side, so a hidden nav item never widens
access.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: The caller's identity.
@@ -2897,11 +2892,11 @@ paths:
type: boolean
description: >-
True only when role is admin or owner AND the request arrived
via the admin Access path (Principal.IsAdmin()).
on the operator console host (Principal.IsAdmin()).
is_owner:
type: boolean
description: >-
True only for the Owner principal on the admin Access path
True only for the Owner principal on the operator console host
(Principal.IsOwner()); gates owner-only panel surfaces.
email_verified:
type: boolean
@@ -2918,7 +2913,7 @@ paths:
summary: List the servers the caller owns or may claim.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: The caller's server list.
@@ -2948,7 +2943,7 @@ paths:
no behavior yet.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: The current maintenance window (both ends null when unset).
@@ -2974,7 +2969,7 @@ paths:
component degrades to notify.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -3007,7 +3002,7 @@ paths:
max_age_seconds. Read-only: backups run on the host, never through the API.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: The newest recorded backup and whether it is stale.
@@ -3034,7 +3029,7 @@ paths:
for display — best-effort, so a Postgres blip degrades to owner-less rows.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: Every server's status projection (fleet-wide), each with its owner.
@@ -3059,7 +3054,7 @@ paths:
summary: List world backups (admin sees all; a user sees only worlds they formerly owned).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: Visible backups.
@@ -3091,7 +3086,7 @@ paths:
Pass safety_snapshot false to restore straight away.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
requestBody:
@@ -3154,7 +3149,7 @@ paths:
asynchronously as a Job, so success is 202 (backing_up).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
responses:
@@ -3198,7 +3193,7 @@ paths:
"running" | "succeeded" | "failed".
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
responses:
@@ -3267,7 +3262,7 @@ paths:
Listings are capped; truncated reports that the cap was hit.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
@@ -3336,7 +3331,7 @@ paths:
subsequent save destroy the other half.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
@@ -3408,7 +3403,7 @@ paths:
otherwise 409 file_changed. Audited as file.write.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
@@ -3492,7 +3487,7 @@ paths:
first. Every route under /users gates on the admin Zero-Trust path.
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: query, in: query, required: false, schema: { type: string }, description: Substring match on username or email }
- { name: role, in: query, required: false, schema: { type: string, enum: [admin, user] } }
@@ -3522,7 +3517,7 @@ paths:
summary: Create a user (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -3562,7 +3557,7 @@ paths:
summary: Get user detail (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
@@ -3583,7 +3578,7 @@ paths:
summary: Edit a user (admin only, cannot patch self).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
@@ -3625,7 +3620,7 @@ paths:
summary: Soft-delete a user — releases servers, revokes sessions (admin only, cannot delete self).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
@@ -3659,7 +3654,7 @@ paths:
immediate. Re-enabling simply clears the flag.
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
@@ -3700,7 +3695,7 @@ paths:
summary: Get a user's quotas (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
@@ -3719,7 +3714,7 @@ paths:
summary: Set a user's quotas (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
@@ -3753,7 +3748,7 @@ paths:
summary: List a user's live sessions (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
@@ -3778,7 +3773,7 @@ paths:
summary: Revoke every live session of a user (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
@@ -3803,7 +3798,7 @@ paths:
summary: Revoke a single session of a user (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: hash, in: path, required: true, schema: { type: string } }
@@ -3844,7 +3839,7 @@ paths:
no-op, not a 404.
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
@@ -3875,7 +3870,7 @@ paths:
protection.
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
@@ -3919,7 +3914,7 @@ paths:
summary: Remove a single Minecraft UUID binding from a user (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
@@ -3951,7 +3946,7 @@ paths:
summary: Report account-link status and in-game instructions (web side, spec §10).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: Current link status.
@@ -3973,7 +3968,7 @@ paths:
summary: Consume an in-game link code and bind the account.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -4024,7 +4019,7 @@ paths:
session must have reauthed within 5 minutes (403 reauth_required).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -4085,7 +4080,7 @@ paths:
consumed, or mismatched code is a 400.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -4135,7 +4130,7 @@ paths:
must have reauthed within 5 minutes (403 reauth_required).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -4180,7 +4175,7 @@ paths:
instance.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: "WebAuthn credential-creation options (the publicKey document)."
@@ -4214,7 +4209,7 @@ paths:
configured.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -4262,7 +4257,7 @@ paths:
not need the WebAuthn verifier, so it succeeds even where begin/finish report 503.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: The caller's bound passkeys.
@@ -4294,7 +4289,7 @@ paths:
(403 reauth_required).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- name: id
in: path
@@ -4333,7 +4328,7 @@ paths:
op-login or a passkey).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: Where the caller stands.
@@ -4361,7 +4356,7 @@ paths:
bound to a fresh reauth-purpose challenge.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get.
@@ -4388,7 +4383,7 @@ paths:
clone check (a cloned authenticator is 400 passkey_login_invalid).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -4423,7 +4418,7 @@ paths:
signing in again (403 staff_reauth).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'202':
description: Code minted and dispatched.
@@ -4471,7 +4466,7 @@ paths:
summary: Redeem the reauth code and mark this session reauthed for 5 minutes.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -4515,12 +4510,11 @@ paths:
operationId: listMySessions
summary: List the caller's own live sessions, marking the one this request came in on.
description: >
Every device signed in to the caller's account, most recently seen first. A
caller signed in through Cloudflare Access has no session of its own, so no
entry is marked current.
Every device signed in to the caller's account, most recently seen first,
with the one this request came in on marked current.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: The caller's live sessions.
@@ -4547,7 +4541,7 @@ paths:
a sign-out; the cookie is cleared and signed_out is true.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: hash, in: path, required: true, schema: { type: string } }
responses:
@@ -4578,7 +4572,7 @@ paths:
summary: Sign out every session of the caller except the one making this request.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: Other sessions revoked.
@@ -4607,7 +4601,7 @@ paths:
caller has no live migration.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: The caller's live migration, or active:false.
@@ -4643,7 +4637,7 @@ paths:
of band and never returned; requires a verified email on the account.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'202':
description: Confirmation code minted and dispatched.
@@ -4695,7 +4689,7 @@ paths:
unknown, expired, consumed, or mismatched code is a 400 invalid_code.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -4753,7 +4747,7 @@ paths:
the login door does, runs the clone-signal (sign-count) check before confirming.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get.
@@ -4787,7 +4781,7 @@ paths:
success the migration advances to confirmed with confirm_factor passkey.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -4836,7 +4830,7 @@ paths:
must exist and be neither disabled nor soft-deleted, and cannot be the source.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -4890,7 +4884,7 @@ paths:
keeps its own in-game identity and credentials; only server ownership moves.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -4929,7 +4923,7 @@ paths:
summary: Submit a modpack for admin review (user side; user-directed lane over §16). Starts no build.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -4970,7 +4964,7 @@ paths:
summary: List the caller's own modpack submissions with each linked build's outcome (user-directed lane over §16).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: >-
@@ -5009,7 +5003,7 @@ paths:
store has no implemented upload transport.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
@@ -5056,7 +5050,7 @@ paths:
this endpoint cannot probe or clear another user's uploads.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
@@ -5085,7 +5079,7 @@ paths:
summary: Mutate a server spec (admin). Storage is immutable.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
requestBody:
@@ -5163,7 +5157,7 @@ paths:
demand) and leave out the Dockerfile, which GET /images/build/{id} returns.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: query, in: query, required: false, schema: { type: string }, description: 'Build id or status (exact), or part of the image ref; case-insensitive' }
- { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
@@ -5193,7 +5187,7 @@ paths:
summary: Submit an image build (admin). A build is build-time RCE against the cluster.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -5242,7 +5236,7 @@ paths:
summary: Get one build's status (admin).
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
@@ -5267,7 +5261,7 @@ paths:
summary: Stream a build's Job log over SSE (admin, spec §16 / §416).
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- name: Last-Event-ID
@@ -5299,7 +5293,7 @@ paths:
summary: Cancel a running build (admin).
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
@@ -5329,7 +5323,7 @@ paths:
summary: List whitelisted images (admin).
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: The image whitelist.
@@ -5354,7 +5348,7 @@ paths:
summary: Whitelist an externally-built image by reference (admin).
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
@@ -5384,7 +5378,7 @@ paths:
summary: Remove an image from the whitelist by reference (admin).
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: ref, in: query, required: true, schema: { type: string } }
responses:
@@ -5408,7 +5402,7 @@ paths:
summary: The admin review queue — every user's modpack submissions (admin; user-directed lane over §16).
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
responses:
'200':
description: All submissions, newest first.
@@ -5437,7 +5431,7 @@ paths:
Approval is layered in front of the scan, never instead of it.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
@@ -5475,7 +5469,7 @@ paths:
deployment's context store has no implemented transport.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
@@ -5500,7 +5494,7 @@ paths:
summary: Reject a submission with a required reason (admin; user-directed lane over §16). Starts no build.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
@@ -5548,7 +5542,7 @@ paths:
fetch; the admin has explicitly chosen to retire the artifact.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
+23 -36
View File
@@ -304,45 +304,32 @@ point.
## 5. Web panel returns 401 / 403 (Zero-Trust / Cloudflare Access)
The external face accepts either a Cloudflare Access JWT
(`Cf-Access-Jwt-Assertion` header) **or** a local session cookie. The error
envelope is always `{"error":{"code","message","request_id"}}`. [GO-TESTED.]
The external face has one credential: the `felis_session` cookie the sign-in
doors mint. Cloudflare Access, when the install sits behind it, is enforced at
the Cloudflare edge only — felis-api does not read the `Cf-Access-Jwt-Assertion`
header, so a request that reaches the origin some other way still has to sign in,
and the account and its role always come from the `users` table. The edge setup
fences the panel NodePort to loopback (the `felis_edge` nftables table), so every
request reaches the API through cloudflared and Access stays in front of the
operator console; check `nft list table inet felis_edge` if you doubt it. The error envelope is always
`{"error":{"code","message","request_id"}}`. [GO-TESTED.]
- **`401 unauthorized`** — not authenticated: no/invalid Access JWT and no valid
session. [GO-TESTED.]
- **`401 unauthorized`** — no valid session cookie. [GO-TESTED.]
- **`403 forbidden`** — authenticated but not permitted (e.g. a non-admin
principal hitting an admin route; `IsAdmin()` requires `role=admin` **and**
arrival via the admin Access audience/host). [GO-TESTED.]
principal hitting an admin route; `IsAdmin()` requires a staff role **and** a
request on the operator console host). [GO-TESTED.]
### 5a. Every external request 401s on a fresh deploy
### 5a. Staff routes 403 on a local IP URL
The Access verifier is wired **fail-closed**: `Keyfunc` (the JWKS key function)
is `nil` until deployment wiring supplies it. With a nil Keyfunc, **every** JWT
verification fails, and startup logs:
```
felis api: external face fails closed (Access JWKS key function not configured)
```
[INTEGRATION-ONLY — the live JWKS path is a deployment point.] This is intended:
the panel rejects all callers until JWKS is configured. Fix by wiring the
Access JWKS key function for `cfg.Auth.AccessJWTAud`.
### 5b. Token rejected with audience error
```
token audience does not include "<aud>"
```
The JWT's `aud` claim does not contain the configured `cfg.Auth.AccessJWTAud`
(or the admin audience for admin routes). [GO-TESTED.] Confirm the Access
application audience matches `cfg.Auth.AccessJWTAud`.
**Trust-model note for operators:** verification is **expiration-required +
audience + signing-key (JWKS)**. There is **no `iss` (issuer) check** anywhere in
the verifier. Trust rests entirely on the audience claim plus the JWKS signing
key. When documenting or auditing the trust boundary, do not assume issuer is
validated — it is not.
The operator console is recognised by the request's host: `admin_hostname`
(default `op.console.<root_domain>`). A bare IP counts only when the install
names it — the address a `<ip>.nip.io` / `<ip>.sslip.io` root domain embeds
(the local panel URL `felis setup` prints), or an `admin_hostname` set to that
IP. Any other address, loopback included, is served as the player console, so a
staff account signed in at `https://127.0.0.1:30443` through an SSH tunnel gets
403 on admin routes. Open the console by its hostname instead (an `/etc/hosts`
entry or `curl --resolve` pointing it at the tunnel), or set
`[auth] admin_hostname` to the IP you use. [GO-TESTED]
### 5c. Local-password login fails or is silently rejected
@@ -353,7 +340,7 @@ unparseable → treated as disabled). Symptoms:
- Cookie present but login rejected with `local auth disabled` → the
`local_auth_enabled` setting is false/absent. A present cookie under disabled
local-auth is **rejected outright**, not fallen through to the JWT path.
local-auth is **rejected outright**.
- `invalid session: …` → bad/forged session hash.
Fix: set `local_auth_enabled=true` in `platform_settings` if local password auth
+1 -1
View File
@@ -11,7 +11,6 @@ require (
github.com/descope/virtualwebauthn v1.0.5
github.com/go-logr/logr v1.4.2
github.com/go-webauthn/webauthn v0.17.4
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/minio/minio-go/v7 v7.2.1
@@ -49,6 +48,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
+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"`