From 38288e1c60b3f4ffe93033196e495a036fa8866b Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 15:35:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E5=88=A0=E9=99=A4=E6=9C=AA?= =?UTF-8?q?=E6=8E=A5=E9=80=9A=E7=9A=84=20Access=20JWT=20=E5=A7=94=E6=89=98?= =?UTF-8?q?=EF=BC=8C=E5=A4=96=E9=83=A8=E9=9D=A2=E5=8F=AA=E8=AE=A4=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=20cookie=EF=BC=8Cadmin=20=E4=B8=BB=E6=9C=BA=E7=9A=84?= =?UTF-8?q?=20IP=20=E5=88=A4=E5=AE=9A=E5=8F=AA=E8=AE=A4=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E7=9A=84=E5=9C=B0=E5=9D=80=EF=BC=8C=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E4=B8=8E=20OpenAPI=20=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/felis/api.go | 18 +- docs/openapi.yaml | 212 +++++++++++----------- docs/troubleshooting.md | 59 +++--- go.mod | 2 +- internal/api/api.go | 19 +- internal/api/api_test.go | 123 +++++-------- internal/api/audit.go | 4 +- internal/api/auth.go | 120 ++---------- internal/api/handlers_account_sessions.go | 4 +- internal/api/handlers_op_login_test.go | 2 +- internal/api/middleware.go | 4 +- internal/api/pgrepo.go | 4 +- internal/api/session.go | 65 ++++--- internal/cfsetup/cfsetup.go | 14 +- internal/config/config.go | 6 +- 15 files changed, 274 insertions(+), 382 deletions(-) diff --git a/cmd/felis/api.go b/cmd/felis/api.go index e32e8c4..6a42d1d 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -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 { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a11655a..3814206 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 ` 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: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f2ec637..a8794e3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 "" -``` - -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.`). A bare IP counts only when the install +names it — the address a `.nip.io` / `.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 diff --git a/go.mod b/go.mod index 20f0b03..80ce152 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/internal/api/api.go b/internal/api/api.go index 1bd998e..14a86a6 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 8eaac52..870ddc2 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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. +// 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: "u@example.net", 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 != "u@example.net" || 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 diff --git a/internal/api/audit.go b/internal/api/audit.go index bb7e50c..4b9e8f9 100644 --- a/internal/api/audit.go +++ b/internal/api/audit.go @@ -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 { diff --git a/internal/api/auth.go b/internal/api/auth.go index 08b7944..2cae168 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -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 -} diff --git a/internal/api/handlers_account_sessions.go b/internal/api/handlers_account_sessions.go index 6ae4b9b..bb464a9 100644 --- a/internal/api/handlers_account_sessions.go +++ b/internal/api/handlers_account_sessions.go @@ -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 "" diff --git a/internal/api/handlers_op_login_test.go b/internal/api/handlers_op_login_test.go index 2bf6b34..a27f87d 100644 --- a/internal/api/handlers_op_login_test.go +++ b/internal/api/handlers_op_login_test.go @@ -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() diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 40fe083..29fe397 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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) diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index dd6c64b..c8c65ca 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -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, diff --git a/internal/api/session.go b/internal/api/session.go index 181c1be..0c093fd 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -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.. -// 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 +// .nip.io / .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") } diff --git a/internal/cfsetup/cfsetup.go b/internal/cfsetup/cfsetup.go index 15718a3..bbe0e40 100644 --- a/internal/cfsetup/cfsetup.go +++ b/internal/cfsetup/cfsetup.go @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 0edf8d7..5a574ef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"`