fix(api): 删除未接通的 Access JWT 委托,外部面只认会话 cookie,admin 主机的 IP 判定只认安装指定的地址,文档与 OpenAPI 同步
This commit is contained in:
15 files changed
+272
-380
No files matched your search
+5
-13
@@ -60,10 +60,8 @@ func authSourcesFromConfig(configured []config.AuthSourceConfig) []api.AuthSourc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cmdAPI runs felis-api: two listeners, two middleware chains (spec §7). The
|
// 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
|
// internal face authenticates per-caller service tokens; the external face
|
||||||
// fails closed until an Access JWKS key function is configured — the verifier's
|
// authenticates the local session cookie.
|
||||||
// audience logic is unit-tested (internal/api), the JWKS source is a deployment
|
|
||||||
// integration point.
|
|
||||||
func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||||
fs := flag.NewFlagSet("api", flag.ContinueOnError)
|
fs := flag.NewFlagSet("api", flag.ContinueOnError)
|
||||||
fs.SetOutput(stderr)
|
fs.SetOutput(stderr)
|
||||||
@@ -317,16 +315,11 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
|||||||
Files: files,
|
Files: files,
|
||||||
Submissions: submissions,
|
Submissions: submissions,
|
||||||
Mailer: mailer,
|
Mailer: mailer,
|
||||||
// The external face is fronted by SessionAuth: it prefers a local session
|
// The external face authenticates the local session cookie the sign-in doors
|
||||||
// cookie (minted by the passwordless doors) and otherwise delegates to the
|
// mint, live once `felis breakGlass` flips local_auth_enabled on. Cloudflare
|
||||||
// Cloudflare-Access JWT verifier, so both auth models coexist on one face. The
|
// Access, when the install sits behind it, is enforced at the edge only.
|
||||||
// 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.
|
|
||||||
External: api.SessionAuth{
|
External: api.SessionAuth{
|
||||||
Repo: repo,
|
Repo: repo,
|
||||||
Delegate: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
|
|
||||||
RootDomain: cfg.Server.RootDomain,
|
RootDomain: cfg.Server.RootDomain,
|
||||||
AdminHostname: cfg.Auth.AdminHostname,
|
AdminHostname: cfg.Auth.AdminHostname,
|
||||||
},
|
},
|
||||||
@@ -355,7 +348,6 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
|||||||
ClientIPHeader: cfg.Auth.EffectiveClientIPHeader(),
|
ClientIPHeader: cfg.Auth.EffectiveClientIPHeader(),
|
||||||
MailLimit: mailLimit(cfg.SMTP.MaxPerHour),
|
MailLimit: mailLimit(cfg.SMTP.MaxPerHour),
|
||||||
}
|
}
|
||||||
fmt.Fprintln(stderr, "felis api: external face fails closed (Access JWKS key function not configured)")
|
|
||||||
if a.ClientIPHeader != "" {
|
if a.ClientIPHeader != "" {
|
||||||
fmt.Fprintf(stderr, "felis api: sign-in rate limit keys on the %s header\n", a.ClientIPHeader)
|
fmt.Fprintf(stderr, "felis api: sign-in rate limit keys on the %s header\n", a.ClientIPHeader)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+103
-109
@@ -26,10 +26,11 @@ info:
|
|||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
description: |
|
description: |
|
||||||
Control plane for the Felis Minecraft orchestration platform. The same binary
|
Control plane for the Felis Minecraft orchestration platform. The same binary
|
||||||
exposes an internal face (service-token auth, for velocity / backend callbacks,
|
exposes an internal face (per-caller service tokens, for velocity / backend
|
||||||
never Zero Trust) and an external face (Cloudflare Access JWT auth, for people
|
callbacks, never Zero Trust) and an external face (the felis_session cookie, for
|
||||||
and the panel). Admin-tier external operations additionally require the admin
|
people and the panel; Cloudflare Access, when present, is enforced at the edge).
|
||||||
Access path. See `x-felis-face` / `x-felis-tier` on each operation.
|
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:
|
Behaviour every operation shares, and so not repeated under each:
|
||||||
|
|
||||||
@@ -63,8 +64,9 @@ servers:
|
|||||||
default: example.test
|
default: example.test
|
||||||
- url: https://api.{root_domain}
|
- url: https://api.{root_domain}
|
||||||
description: >-
|
description: >-
|
||||||
External face. Cloudflare Access JWT auth on every /api/v1 route; admin-tier
|
External face. Session-cookie auth on every non-public /api/v1 route;
|
||||||
routes additionally require the admin Access path.
|
admin-tier routes additionally require a staff session on the operator
|
||||||
|
console host.
|
||||||
variables:
|
variables:
|
||||||
root_domain:
|
root_domain:
|
||||||
default: example.test
|
default: example.test
|
||||||
@@ -105,14 +107,6 @@ components:
|
|||||||
callers it serves in x-felis-callers. A genuine token for a caller the
|
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
|
operation does not list is refused with 403 wrong_caller. `felis
|
||||||
rotate-token <caller>` replaces one.
|
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:
|
sessionCookie:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
in: cookie
|
in: cookie
|
||||||
@@ -122,8 +116,10 @@ components:
|
|||||||
session doors — passkey login, email-OTP, bind code, and op-login
|
session doors — passkey login, email-OTP, bind code, and op-login
|
||||||
finish — HttpOnly+Secure+SameSite=Lax and host-only, so an op.console
|
finish — HttpOnly+Secure+SameSite=Lax and host-only, so an op.console
|
||||||
session never reaches the player console. Only its sha-256 is
|
session never reaches the player console. Only its sha-256 is
|
||||||
persisted. SessionAuth prefers this cookie and otherwise delegates to
|
persisted. It is the external face's only credential: Cloudflare
|
||||||
accessJWT, so the two models coexist on one face.
|
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:
|
responses:
|
||||||
NoContent:
|
NoContent:
|
||||||
@@ -785,14 +781,14 @@ paths:
|
|||||||
operationId: createServer
|
operationId: createServer
|
||||||
summary: Create a server (admin).
|
summary: Create a server (admin).
|
||||||
description: >-
|
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
|
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;
|
(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
|
400 image_not_in_registry when the registry lacks the tag, 503
|
||||||
registry_unavailable when it cannot be asked.
|
registry_unavailable when it cannot be asked.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -1444,7 +1440,7 @@ paths:
|
|||||||
summary: Wake your own server.
|
summary: Wake your own server.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -1482,7 +1478,7 @@ paths:
|
|||||||
summary: Stop your own server.
|
summary: Stop your own server.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -1510,7 +1506,7 @@ paths:
|
|||||||
summary: Claim an unowned server for your linked account.
|
summary: Claim an unowned server for your linked account.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -1552,7 +1548,7 @@ paths:
|
|||||||
description: The RCON password is never accepted or returned (spec §286).
|
description: The RCON password is never accepted or returned (spec §286).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -1598,7 +1594,7 @@ paths:
|
|||||||
summary: Stream the running pod's log over SSE (spec §8 read, §262). Owner/admin only.
|
summary: Stream the running pod's log over SSE (spec §8 read, §262). Owner/admin only.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
- name: Last-Event-ID
|
- name: Last-Event-ID
|
||||||
@@ -1637,7 +1633,7 @@ paths:
|
|||||||
(spec §286).
|
(spec §286).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -1676,7 +1672,7 @@ paths:
|
|||||||
(spec §286).
|
(spec §286).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -1720,7 +1716,7 @@ paths:
|
|||||||
The RCON password is never accepted or returned (spec §286).
|
The RCON password is never accepted or returned (spec §286).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -1762,7 +1758,7 @@ paths:
|
|||||||
(spec §286).
|
(spec §286).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -1800,7 +1796,7 @@ paths:
|
|||||||
records intent). The RCON password is never accepted or returned (§286).
|
records intent). The RCON password is never accepted or returned (§286).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -1844,7 +1840,7 @@ paths:
|
|||||||
never accepted or returned (spec §286).
|
never accepted or returned (spec §286).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -1897,7 +1893,7 @@ paths:
|
|||||||
(spec §286).
|
(spec §286).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -1943,7 +1939,7 @@ paths:
|
|||||||
password is never accepted or returned (spec §286).
|
password is never accepted or returned (spec §286).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -1989,7 +1985,7 @@ paths:
|
|||||||
is echoed back for anything the parser cannot represent.
|
is echoed back for anything the parser cannot represent.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
- { name: player, 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.
|
displayName, phase, ready, playersOnline and playersMax.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -2870,14 +2866,13 @@ paths:
|
|||||||
summary: The caller's own identity and tier (drives panel navigation).
|
summary: The caller's own identity and tier (drives panel navigation).
|
||||||
description: >-
|
description: >-
|
||||||
Returns the authenticated principal's user id, email, role and the
|
Returns the authenticated principal's user id, email, role and the
|
||||||
server-computed is_admin (Principal.IsAdmin(): role admin reached via the
|
server-computed is_admin (Principal.IsAdmin(): role admin reached on the operator console host). The panel reads this once at boot to decide which
|
||||||
admin Access path). The panel reads this once at boot to decide which
|
|
||||||
surfaces to render. It is UX truth, not a security control — admin routes
|
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
|
are independently gated server-side, so a hidden nav item never widens
|
||||||
access.
|
access.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The caller's identity.
|
description: The caller's identity.
|
||||||
@@ -2897,11 +2892,11 @@ paths:
|
|||||||
type: boolean
|
type: boolean
|
||||||
description: >-
|
description: >-
|
||||||
True only when role is admin or owner AND the request arrived
|
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:
|
is_owner:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: >-
|
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.
|
(Principal.IsOwner()); gates owner-only panel surfaces.
|
||||||
email_verified:
|
email_verified:
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -2918,7 +2913,7 @@ paths:
|
|||||||
summary: List the servers the caller owns or may claim.
|
summary: List the servers the caller owns or may claim.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The caller's server list.
|
description: The caller's server list.
|
||||||
@@ -2948,7 +2943,7 @@ paths:
|
|||||||
no behavior yet.
|
no behavior yet.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The current maintenance window (both ends null when unset).
|
description: The current maintenance window (both ends null when unset).
|
||||||
@@ -2974,7 +2969,7 @@ paths:
|
|||||||
component degrades to notify.
|
component degrades to notify.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -3007,7 +3002,7 @@ paths:
|
|||||||
max_age_seconds. Read-only: backups run on the host, never through the API.
|
max_age_seconds. Read-only: backups run on the host, never through the API.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The newest recorded backup and whether it is stale.
|
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.
|
for display — best-effort, so a Postgres blip degrades to owner-less rows.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Every server's status projection (fleet-wide), each with its owner.
|
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).
|
summary: List world backups (admin sees all; a user sees only worlds they formerly owned).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Visible backups.
|
description: Visible backups.
|
||||||
@@ -3091,7 +3086,7 @@ paths:
|
|||||||
Pass safety_snapshot false to restore straight away.
|
Pass safety_snapshot false to restore straight away.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -3154,7 +3149,7 @@ paths:
|
|||||||
asynchronously as a Job, so success is 202 (backing_up).
|
asynchronously as a Job, so success is 202 (backing_up).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -3198,7 +3193,7 @@ paths:
|
|||||||
"running" | "succeeded" | "failed".
|
"running" | "succeeded" | "failed".
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -3267,7 +3262,7 @@ paths:
|
|||||||
Listings are capped; truncated reports that the cap was hit.
|
Listings are capped; truncated reports that the cap was hit.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
- name: path
|
- name: path
|
||||||
@@ -3336,7 +3331,7 @@ paths:
|
|||||||
subsequent save destroy the other half.
|
subsequent save destroy the other half.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
- name: path
|
- name: path
|
||||||
@@ -3408,7 +3403,7 @@ paths:
|
|||||||
otherwise 409 file_changed. Audited as file.write.
|
otherwise 409 file_changed. Audited as file.write.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
- name: path
|
- name: path
|
||||||
@@ -3492,7 +3487,7 @@ paths:
|
|||||||
first. Every route under /users gates on the admin Zero-Trust path.
|
first. Every route under /users gates on the admin Zero-Trust path.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: query, in: query, required: false, schema: { type: string }, description: Substring match on username or email }
|
- { 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] } }
|
- { name: role, in: query, required: false, schema: { type: string, enum: [admin, user] } }
|
||||||
@@ -3522,7 +3517,7 @@ paths:
|
|||||||
summary: Create a user (admin only).
|
summary: Create a user (admin only).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -3562,7 +3557,7 @@ paths:
|
|||||||
summary: Get user detail (admin only).
|
summary: Get user detail (admin only).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -3583,7 +3578,7 @@ paths:
|
|||||||
summary: Edit a user (admin only, cannot patch self).
|
summary: Edit a user (admin only, cannot patch self).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -3625,7 +3620,7 @@ paths:
|
|||||||
summary: Soft-delete a user — releases servers, revokes sessions (admin only, cannot delete self).
|
summary: Soft-delete a user — releases servers, revokes sessions (admin only, cannot delete self).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -3659,7 +3654,7 @@ paths:
|
|||||||
immediate. Re-enabling simply clears the flag.
|
immediate. Re-enabling simply clears the flag.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -3700,7 +3695,7 @@ paths:
|
|||||||
summary: Get a user's quotas (admin only).
|
summary: Get a user's quotas (admin only).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -3719,7 +3714,7 @@ paths:
|
|||||||
summary: Set a user's quotas (admin only).
|
summary: Set a user's quotas (admin only).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -3753,7 +3748,7 @@ paths:
|
|||||||
summary: List a user's live sessions (admin only).
|
summary: List a user's live sessions (admin only).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -3778,7 +3773,7 @@ paths:
|
|||||||
summary: Revoke every live session of a user (admin only).
|
summary: Revoke every live session of a user (admin only).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -3803,7 +3798,7 @@ paths:
|
|||||||
summary: Revoke a single session of a user (admin only).
|
summary: Revoke a single session of a user (admin only).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
- { name: hash, 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.
|
no-op, not a 404.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -3875,7 +3870,7 @@ paths:
|
|||||||
protection.
|
protection.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -3919,7 +3914,7 @@ paths:
|
|||||||
summary: Remove a single Minecraft UUID binding from a user (admin only).
|
summary: Remove a single Minecraft UUID binding from a user (admin only).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
- { 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).
|
summary: Report account-link status and in-game instructions (web side, spec §10).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Current link status.
|
description: Current link status.
|
||||||
@@ -3973,7 +3968,7 @@ paths:
|
|||||||
summary: Consume an in-game link code and bind the account.
|
summary: Consume an in-game link code and bind the account.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -4024,7 +4019,7 @@ paths:
|
|||||||
session must have reauthed within 5 minutes (403 reauth_required).
|
session must have reauthed within 5 minutes (403 reauth_required).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -4085,7 +4080,7 @@ paths:
|
|||||||
consumed, or mismatched code is a 400.
|
consumed, or mismatched code is a 400.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -4135,7 +4130,7 @@ paths:
|
|||||||
must have reauthed within 5 minutes (403 reauth_required).
|
must have reauthed within 5 minutes (403 reauth_required).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -4180,7 +4175,7 @@ paths:
|
|||||||
instance.
|
instance.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: "WebAuthn credential-creation options (the publicKey document)."
|
description: "WebAuthn credential-creation options (the publicKey document)."
|
||||||
@@ -4214,7 +4209,7 @@ paths:
|
|||||||
configured.
|
configured.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -4262,7 +4257,7 @@ paths:
|
|||||||
not need the WebAuthn verifier, so it succeeds even where begin/finish report 503.
|
not need the WebAuthn verifier, so it succeeds even where begin/finish report 503.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The caller's bound passkeys.
|
description: The caller's bound passkeys.
|
||||||
@@ -4294,7 +4289,7 @@ paths:
|
|||||||
(403 reauth_required).
|
(403 reauth_required).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
in: path
|
in: path
|
||||||
@@ -4333,7 +4328,7 @@ paths:
|
|||||||
op-login or a passkey).
|
op-login or a passkey).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Where the caller stands.
|
description: Where the caller stands.
|
||||||
@@ -4361,7 +4356,7 @@ paths:
|
|||||||
bound to a fresh reauth-purpose challenge.
|
bound to a fresh reauth-purpose challenge.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get.
|
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).
|
clone check (a cloned authenticator is 400 passkey_login_invalid).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -4423,7 +4418,7 @@ paths:
|
|||||||
signing in again (403 staff_reauth).
|
signing in again (403 staff_reauth).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'202':
|
'202':
|
||||||
description: Code minted and dispatched.
|
description: Code minted and dispatched.
|
||||||
@@ -4471,7 +4466,7 @@ paths:
|
|||||||
summary: Redeem the reauth code and mark this session reauthed for 5 minutes.
|
summary: Redeem the reauth code and mark this session reauthed for 5 minutes.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -4515,12 +4510,11 @@ paths:
|
|||||||
operationId: listMySessions
|
operationId: listMySessions
|
||||||
summary: List the caller's own live sessions, marking the one this request came in on.
|
summary: List the caller's own live sessions, marking the one this request came in on.
|
||||||
description: >
|
description: >
|
||||||
Every device signed in to the caller's account, most recently seen first. A
|
Every device signed in to the caller's account, most recently seen first,
|
||||||
caller signed in through Cloudflare Access has no session of its own, so no
|
with the one this request came in on marked current.
|
||||||
entry is marked current.
|
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The caller's live sessions.
|
description: The caller's live sessions.
|
||||||
@@ -4547,7 +4541,7 @@ paths:
|
|||||||
a sign-out; the cookie is cleared and signed_out is true.
|
a sign-out; the cookie is cleared and signed_out is true.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: hash, in: path, required: true, schema: { type: string } }
|
- { name: hash, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -4578,7 +4572,7 @@ paths:
|
|||||||
summary: Sign out every session of the caller except the one making this request.
|
summary: Sign out every session of the caller except the one making this request.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Other sessions revoked.
|
description: Other sessions revoked.
|
||||||
@@ -4607,7 +4601,7 @@ paths:
|
|||||||
caller has no live migration.
|
caller has no live migration.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The caller's live migration, or active:false.
|
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.
|
of band and never returned; requires a verified email on the account.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'202':
|
'202':
|
||||||
description: Confirmation code minted and dispatched.
|
description: Confirmation code minted and dispatched.
|
||||||
@@ -4695,7 +4689,7 @@ paths:
|
|||||||
unknown, expired, consumed, or mismatched code is a 400 invalid_code.
|
unknown, expired, consumed, or mismatched code is a 400 invalid_code.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -4753,7 +4747,7 @@ paths:
|
|||||||
the login door does, runs the clone-signal (sign-count) check before confirming.
|
the login door does, runs the clone-signal (sign-count) check before confirming.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get.
|
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.
|
success the migration advances to confirmed with confirm_factor passkey.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -4836,7 +4830,7 @@ paths:
|
|||||||
must exist and be neither disabled nor soft-deleted, and cannot be the source.
|
must exist and be neither disabled nor soft-deleted, and cannot be the source.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -4890,7 +4884,7 @@ paths:
|
|||||||
keeps its own in-game identity and credentials; only server ownership moves.
|
keeps its own in-game identity and credentials; only server ownership moves.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -4929,7 +4923,7 @@ paths:
|
|||||||
summary: Submit a modpack for admin review (user side; user-directed lane over §16). Starts no build.
|
summary: Submit a modpack for admin review (user side; user-directed lane over §16). Starts no build.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
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).
|
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-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: >-
|
description: >-
|
||||||
@@ -5009,7 +5003,7 @@ paths:
|
|||||||
store has no implemented upload transport.
|
store has no implemented upload transport.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -5056,7 +5050,7 @@ paths:
|
|||||||
this endpoint cannot probe or clear another user's uploads.
|
this endpoint cannot probe or clear another user's uploads.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -5085,7 +5079,7 @@ paths:
|
|||||||
summary: Mutate a server spec (admin). Storage is immutable.
|
summary: Mutate a server spec (admin). Storage is immutable.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -5163,7 +5157,7 @@ paths:
|
|||||||
demand) and leave out the Dockerfile, which GET /images/build/{id} returns.
|
demand) and leave out the Dockerfile, which GET /images/build/{id} returns.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
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: 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 } }
|
- { 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.
|
summary: Submit an image build (admin). A build is build-time RCE against the cluster.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -5242,7 +5236,7 @@ paths:
|
|||||||
summary: Get one build's status (admin).
|
summary: Get one build's status (admin).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -5267,7 +5261,7 @@ paths:
|
|||||||
summary: Stream a build's Job log over SSE (admin, spec §16 / §416).
|
summary: Stream a build's Job log over SSE (admin, spec §16 / §416).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
- name: Last-Event-ID
|
- name: Last-Event-ID
|
||||||
@@ -5299,7 +5293,7 @@ paths:
|
|||||||
summary: Cancel a running build (admin).
|
summary: Cancel a running build (admin).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -5329,7 +5323,7 @@ paths:
|
|||||||
summary: List whitelisted images (admin).
|
summary: List whitelisted images (admin).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The image whitelist.
|
description: The image whitelist.
|
||||||
@@ -5354,7 +5348,7 @@ paths:
|
|||||||
summary: Whitelist an externally-built image by reference (admin).
|
summary: Whitelist an externally-built image by reference (admin).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -5384,7 +5378,7 @@ paths:
|
|||||||
summary: Remove an image from the whitelist by reference (admin).
|
summary: Remove an image from the whitelist by reference (admin).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: ref, in: query, required: true, schema: { type: string } }
|
- { name: ref, in: query, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -5408,7 +5402,7 @@ paths:
|
|||||||
summary: The admin review queue — every user's modpack submissions (admin; user-directed lane over §16).
|
summary: The admin review queue — every user's modpack submissions (admin; user-directed lane over §16).
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: All submissions, newest first.
|
description: All submissions, newest first.
|
||||||
@@ -5437,7 +5431,7 @@ paths:
|
|||||||
Approval is layered in front of the scan, never instead of it.
|
Approval is layered in front of the scan, never instead of it.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -5475,7 +5469,7 @@ paths:
|
|||||||
deployment's context store has no implemented transport.
|
deployment's context store has no implemented transport.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
@@ -5500,7 +5494,7 @@ paths:
|
|||||||
summary: Reject a submission with a required reason (admin; user-directed lane over §16). Starts no build.
|
summary: Reject a submission with a required reason (admin; user-directed lane over §16). Starts no build.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -5548,7 +5542,7 @@ paths:
|
|||||||
fetch; the admin has explicitly chosen to retire the artifact.
|
fetch; the admin has explicitly chosen to retire the artifact.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: admin
|
x-felis-tier: admin
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ sessionCookie: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
+23
-36
@@ -304,45 +304,32 @@ point.
|
|||||||
|
|
||||||
## 5. Web panel returns 401 / 403 (Zero-Trust / Cloudflare Access)
|
## 5. Web panel returns 401 / 403 (Zero-Trust / Cloudflare Access)
|
||||||
|
|
||||||
The external face accepts either a Cloudflare Access JWT
|
The external face has one credential: the `felis_session` cookie the sign-in
|
||||||
(`Cf-Access-Jwt-Assertion` header) **or** a local session cookie. The error
|
doors mint. Cloudflare Access, when the install sits behind it, is enforced at
|
||||||
envelope is always `{"error":{"code","message","request_id"}}`. [GO-TESTED.]
|
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
|
- **`401 unauthorized`** — no valid session cookie. [GO-TESTED.]
|
||||||
session. [GO-TESTED.]
|
|
||||||
- **`403 forbidden`** — authenticated but not permitted (e.g. a non-admin
|
- **`403 forbidden`** — authenticated but not permitted (e.g. a non-admin
|
||||||
principal hitting an admin route; `IsAdmin()` requires `role=admin` **and**
|
principal hitting an admin route; `IsAdmin()` requires a staff role **and** a
|
||||||
arrival via the admin Access audience/host). [GO-TESTED.]
|
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)
|
The operator console is recognised by the request's host: `admin_hostname`
|
||||||
is `nil` until deployment wiring supplies it. With a nil Keyfunc, **every** JWT
|
(default `op.console.<root_domain>`). A bare IP counts only when the install
|
||||||
verification fails, and startup logs:
|
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
|
||||||
felis api: external face fails closed (Access JWKS key function not configured)
|
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
|
||||||
[INTEGRATION-ONLY — the live JWKS path is a deployment point.] This is intended:
|
`[auth] admin_hostname` to the IP you use. [GO-TESTED]
|
||||||
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.
|
|
||||||
|
|
||||||
### 5c. Local-password login fails or is silently rejected
|
### 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
|
- Cookie present but login rejected with `local auth disabled` → the
|
||||||
`local_auth_enabled` setting is false/absent. A present cookie under disabled
|
`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.
|
- `invalid session: …` → bad/forged session hash.
|
||||||
|
|
||||||
Fix: set `local_auth_enabled=true` in `platform_settings` if local password auth
|
Fix: set `local_auth_enabled=true` in `platform_settings` if local password auth
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ require (
|
|||||||
github.com/descope/virtualwebauthn v1.0.5
|
github.com/descope/virtualwebauthn v1.0.5
|
||||||
github.com/go-logr/logr v1.4.2
|
github.com/go-logr/logr v1.4.2
|
||||||
github.com/go-webauthn/webauthn v0.17.4
|
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/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/minio/minio-go/v7 v7.2.1
|
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-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/go-webauthn/x v0.2.6 // indirect
|
github.com/go-webauthn/x v0.2.6 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // 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/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/google/gnostic-models v0.6.8 // indirect
|
github.com/google/gnostic-models v0.6.8 // indirect
|
||||||
|
|||||||
+10
-9
@@ -1,9 +1,10 @@
|
|||||||
// Package api implements felis-api: one binary serving two faces (spec §7).
|
// Package api implements felis-api: one binary serving two faces (spec §7).
|
||||||
//
|
//
|
||||||
// The internal face (velocity / backend callbacks) authenticates with a static
|
// The internal face (velocity / backend callbacks) authenticates with a static
|
||||||
// service token and is never wrapped in Zero Trust. The external face (people /
|
// per-caller service token and is never wrapped in Zero Trust. The external face
|
||||||
// panel) authenticates with a Cloudflare Access JWT; admin-tier operations
|
// (people / panel) authenticates the local session cookie; admin-tier operations
|
||||||
// additionally require the admin Access path (spec §14, graded by operation).
|
// 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,
|
// Handlers depend on the Repo and Cluster interfaces, so the request routing,
|
||||||
// dual-face auth, input validation and authorization are all unit-tested with
|
// 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
|
// 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
|
// 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
|
// external face's auth guard already precludes) shares one bucket, which is safe
|
||||||
// because it is more restrictive, never less.
|
// 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):
|
// 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
|
// session auth on every non-public /api/v1 route; the Admin entries are
|
||||||
// additionally gated on the admin Zero-Trust path. It exposes liveness only —
|
// additionally gated on the operator console host. It exposes liveness only —
|
||||||
// readiness is an internal concern.
|
// readiness is an internal concern.
|
||||||
func (a *API) externalAPIRoutes() []apiRoute {
|
func (a *API) externalAPIRoutes() []apiRoute {
|
||||||
return []apiRoute{
|
return []apiRoute{
|
||||||
@@ -691,9 +692,9 @@ func (a *API) InternalHandler() http.Handler {
|
|||||||
return a.buildFace("internal", a.internalAPIRoutes(), a.requireInternal)
|
return a.buildFace("internal", a.internalAPIRoutes(), a.requireInternal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExternalHandler builds the external-face http.Handler: Access-JWT auth on every
|
// ExternalHandler builds the external-face http.Handler: session auth on every
|
||||||
// /api/v1 route, with admin-tier routes additionally gated by the admin Access
|
// non-public /api/v1 route, with admin-tier routes additionally gated on the
|
||||||
// path inside their handlers.
|
// operator console host inside their handlers.
|
||||||
func (a *API) ExternalHandler() http.Handler {
|
func (a *API) ExternalHandler() http.Handler {
|
||||||
return a.buildFace("external", a.externalAPIRoutes(), a.requireExternal)
|
return a.buildFace("external", a.externalAPIRoutes(), a.requireExternal)
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-71
@@ -15,7 +15,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const testRoot = "mc.example.net" // neutral; never a deployment domain
|
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
|
return f.reply, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// staticExternal injects a fixed principal so handler logic is tested without
|
// staticExternal injects a fixed principal so handler logic is tested without a
|
||||||
// real JWT crypto (which is exercised separately in TestAccessVerifier).
|
// session store.
|
||||||
type staticExternal struct {
|
type staticExternal struct {
|
||||||
p *Principal
|
p *Principal
|
||||||
err error
|
err error
|
||||||
@@ -2600,8 +2599,6 @@ func TestErrorEnvelopeHasRequestID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- real AccessVerifier (JWT aud) ----
|
|
||||||
|
|
||||||
func TestSessionAuthUsesConfiguredAdminHostname(t *testing.T) {
|
func TestSessionAuthUsesConfiguredAdminHostname(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
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")
|
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 = httptest.NewRequest("GET", "https://10.211.55.4:30443/api/v1/me", nil)
|
||||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
|
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
|
||||||
p, err = auth.Authenticate(r)
|
p, err = auth.Authenticate(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Authenticate private IP host: %v", err)
|
t.Fatalf("Authenticate private IP host: %v", err)
|
||||||
}
|
}
|
||||||
if !p.ViaAdminAccess {
|
if p.ViaAdminAccess {
|
||||||
t.Fatalf("private IP local panel should grant admin-path access, got %+v", p)
|
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 {
|
// The operator console by Host: the configured name, the op.console.<root>
|
||||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
// fallback, and a bare IP only where the install names it (the nip.io/sslip.io
|
||||||
s, err := tok.SignedString(key)
|
// root domain's embedded address, or an IP admin_hostname).
|
||||||
if err != nil {
|
func TestHostIsAdminConsole(t *testing.T) {
|
||||||
t.Fatalf("sign: %v", err)
|
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},
|
||||||
}
|
}
|
||||||
return s
|
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)
|
||||||
}
|
}
|
||||||
exp := jwt.NewNumericDate(time.Now().Add(time.Hour))
|
}
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("valid app token", func(t *testing.T) {
|
// With no session cookie there is nothing to authenticate: a Cloudflare Access
|
||||||
s := sign(accessClaims{Email: "[email protected]", RegisteredClaims: jwt.RegisteredClaims{
|
// assertion or a bearer JWT is not a credential felis-api accepts.
|
||||||
Subject: "u1", Audience: jwt.ClaimStrings{"felis-app"}, ExpiresAt: exp}})
|
func TestSessionAuthIgnoresAccessAssertions(t *testing.T) {
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
repo := newFakeRepo()
|
||||||
r.Header.Set("Authorization", "Bearer "+s)
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
p, err := v.Authenticate(r)
|
auth := SessionAuth{Repo: repo, RootDomain: "mc.example.net"}
|
||||||
if err != nil {
|
r := httptest.NewRequest("GET", "https://op.console.mc.example.net/api/v1/me", nil)
|
||||||
t.Fatalf("authenticate: %v", err)
|
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)
|
||||||
}
|
}
|
||||||
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")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSessionAuthOutageIs503Not401: a session-store outage must surface as 503
|
// TestSessionAuthOutageIs503Not401: a session-store outage must surface as 503
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// auditActor is the display name for a principal: an email only when something
|
// 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
|
// vouches for it (a session whose address was verified, or an ExternalAuth other
|
||||||
// the username. A player can set their address to anyone's before verifying it,
|
// 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.
|
// so an unverified email would let them sign rows as that person.
|
||||||
func auditActor(p *Principal) string {
|
func auditActor(p *Principal) string {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
+19
-101
@@ -6,29 +6,25 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Principal is the authenticated external-face caller (spec §7, §14). The
|
// Principal is the authenticated external-face caller (spec §7, §14). The
|
||||||
// internal face (service token) never produces a Principal — it is a trusted
|
// internal face (service token) never produces a Principal — it is a trusted
|
||||||
// machine caller, not a person.
|
// machine caller, not a person.
|
||||||
type Principal struct {
|
type Principal struct {
|
||||||
// UserID is the stable web identity (SSO subject → users.id).
|
// UserID is the account's users.id.
|
||||||
UserID string
|
UserID string
|
||||||
// Username is the account's login name; empty for an Access-JWT caller.
|
// Username is the account's login name.
|
||||||
Username string
|
Username string
|
||||||
// Email is the account's address. Only an Access JWT or EmailVerified vouches
|
// Email is the account's address. Only EmailVerified vouches for it: a player
|
||||||
// for it: a player can set any address before verifying it (auditActor).
|
// can set any address before verifying it (auditActor).
|
||||||
Email string
|
Email string
|
||||||
// Role is "owner", "admin", or "user" (mirrors users.role).
|
// Role is "owner", "admin", or "user" (mirrors users.role).
|
||||||
Role string
|
Role string
|
||||||
// ViaAdminAccess is true only when the request arrived through an admin-graded
|
// ViaAdminAccess is true only when a staff session arrived on the operator
|
||||||
// path: the admin.* Zero-Trust hostname (Cloudflare Access, the remote face) OR
|
// console host (hostIsAdminConsole). Admin-tier operations require it in
|
||||||
// a local session presented on the op.console host (SessionAuth, the
|
// addition to a staff role (spec §14: ZT is graded by operation). A staff
|
||||||
// passwordless face). Admin-tier operations require it in addition to
|
// session arriving on the player console (console.*) never sets it.
|
||||||
// a staff role (spec §14: ZT is graded by operation). A staff session
|
|
||||||
// arriving on the player console (console.*) never sets it.
|
|
||||||
ViaAdminAccess bool
|
ViaAdminAccess bool
|
||||||
// EmailVerified mirrors users.email_verified. The lockdown middleware gates
|
// EmailVerified mirrors users.email_verified. The lockdown middleware gates
|
||||||
// setup-incomplete accounts (EmailVerified=false, e.g. a freshly bootstrapped
|
// 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
|
// routes only, so an intercepted setup URL cannot yield full admin access
|
||||||
// before the email-OTP verification step completes.
|
// before the email-OTP verification step completes.
|
||||||
EmailVerified bool
|
EmailVerified bool
|
||||||
// ViaSession is true when the principal was authenticated via a local session
|
// ViaSession is true for every principal SessionAuth resolves from a session
|
||||||
// cookie (SessionAuth), not a Cloudflare-Access JWT. The setup-lockdown gate
|
// cookie. The session-scoped gates (setup lockdown, reauth, the device list)
|
||||||
// only applies to session-authenticated principals — a JWT caller already
|
// key on it.
|
||||||
// passed Zero Trust at the edge, so the local-email-verification gate is not
|
|
||||||
// the right boundary for them.
|
|
||||||
ViaSession bool
|
ViaSession bool
|
||||||
// ReauthAt is when the holder of the session last proved a factor of the
|
// 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
|
ReauthAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +50,8 @@ func staffRole(role string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsAdmin reports whether the principal may perform admin-tier operations.
|
// IsAdmin reports whether the principal may perform admin-tier operations.
|
||||||
// Both the role claim and the admin Access path are required: a staff
|
// Both the staff role and arrival on the operator console host are required: a
|
||||||
// session arriving on panel.* must not bypass the Zero-Trust boundary.
|
// 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).
|
// An owner implicitly passes this check (the owner role is a superset of admin).
|
||||||
func (p *Principal) IsAdmin() bool {
|
func (p *Principal) IsAdmin() bool {
|
||||||
return p != nil && staffRole(p.Role) && p.ViaAdminAccess
|
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
|
// IsOwner reports whether the principal holds the platform-level owner role
|
||||||
// — the single identity that may manage users, quotas, and sessions. Only the
|
// — the single identity that may manage users, quotas, and sessions. Only the
|
||||||
// first staff account minted by break-glass carries this role; every subsequent
|
// 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 {
|
func (p *Principal) IsOwner() bool {
|
||||||
return p != nil && p.Role == "owner" && p.ViaAdminAccess
|
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
|
// ExternalAuth authenticates the external face (people / panel) and returns the
|
||||||
// resolved Principal. Production verifies a Cloudflare Access JWT and checks its
|
// resolved Principal. Production is SessionAuth: the local session cookie the
|
||||||
// audience; the verification key source (JWKS) is injected so the audience and
|
// sign-in doors mint. Cloudflare Access, when an install sits behind it, is
|
||||||
// expiry logic stay unit-testable.
|
// 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 {
|
type ExternalAuth interface {
|
||||||
Authenticate(r *http.Request) (*Principal, error)
|
Authenticate(r *http.Request) (*Principal, error)
|
||||||
}
|
}
|
||||||
@@ -149,64 +144,6 @@ func (c CallerTokens) Authenticate(r *http.Request) (Caller, error) {
|
|||||||
return match, nil
|
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.
|
// bearerToken extracts a Bearer credential from the Authorization header.
|
||||||
func bearerToken(r *http.Request) string {
|
func bearerToken(r *http.Request) string {
|
||||||
const prefix = "Bearer "
|
const prefix = "Bearer "
|
||||||
@@ -216,22 +153,3 @@ func bearerToken(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
return ""
|
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
|
|
||||||
}
|
|
||||||
@@ -92,8 +92,8 @@ func (a *API) revokeOtherSessionsAfter(r *http.Request, change string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// callerSessionHash is the session the request authenticated with, or "" when it
|
// callerSessionHash is the session the request authenticated with, or "" for a
|
||||||
// authenticated some other way (a cookie beside an Access JWT names nothing).
|
// principal that did not come from a session cookie.
|
||||||
func callerSessionHash(r *http.Request, p *Principal) string {
|
func callerSessionHash(r *http.Request, p *Principal) string {
|
||||||
if p == nil || !p.ViaSession {
|
if p == nil || !p.ViaSession {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -543,7 +543,7 @@ func TestOpLoginGates(t *testing.T) {
|
|||||||
|
|
||||||
// TestOpLoginFaceSeparation enforces the two-face split: the three public browser legs
|
// 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
|
// 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) {
|
func TestOpLoginFaceSeparation(t *testing.T) {
|
||||||
api, _, _ := seedOpLoginAPI(t)
|
api, _, _ := seedOpLoginAPI(t)
|
||||||
eh, ih := api.ExternalHandler(), api.InternalHandler()
|
eh, ih := api.ExternalHandler(), api.InternalHandler()
|
||||||
|
|||||||
@@ -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
|
// requireExternal authenticates the external face (SessionAuth in production)
|
||||||
// resolved Principal in the request context.
|
// and stashes the resolved Principal in the request context.
|
||||||
func (a *API) requireExternal(next http.Handler) http.Handler {
|
func (a *API) requireExternal(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
p, err := a.External.Authenticate(r)
|
p, err := a.External.Authenticate(r)
|
||||||
|
|||||||
@@ -782,8 +782,8 @@ func (p *PGRepo) Audit(ctx context.Context, e AuditEntry) error {
|
|||||||
if len(e.Payload) > 0 {
|
if len(e.Payload) > 0 {
|
||||||
payload = string(e.Payload)
|
payload = string(e.Payload)
|
||||||
}
|
}
|
||||||
// actor_user_id goes through a lookup so an id with no users row (an
|
// actor_user_id goes through a lookup so an id with no users row (a purged
|
||||||
// Access-JWT subject, a purged account) lands as NULL instead of failing
|
// account) lands as NULL instead of failing
|
||||||
// the foreign key and losing the row.
|
// the foreign key and losing the row.
|
||||||
_, err := p.db.ExecContext(ctx,
|
_, err := p.db.ExecContext(ctx,
|
||||||
`INSERT INTO audit_logs (actor, source, action, server_name, request_id, payload,
|
`INSERT INTO audit_logs (actor, source, action, server_name, request_id, payload,
|
||||||
|
|||||||
+41
-24
@@ -12,14 +12,14 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Local sessions (spec §B, passwordless). The remote face authenticates statelessly
|
// Local sessions (spec §B, passwordless). Every sign-in door (email-OTP, passkey,
|
||||||
// with a Cloudflare-Access JWT and sets no cookie; the passwordless console login
|
// bind code, op-login, setup redeem) ends in a server-minted session, the external
|
||||||
// (email-OTP / passkey / setup redeem), used on op.console when Zero Trust is not
|
// face's only credential.
|
||||||
// configured (and as the demo's primary web login), needs a server-minted session.
|
|
||||||
// We store only the sha-256 of the opaque cookie value, mirroring how service tokens
|
// 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.
|
// 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
|
// 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
|
// 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>.
|
// 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 {
|
func hostIsAdminConsole(r *http.Request, rootDomain, adminHostname string) bool {
|
||||||
want := strings.TrimSpace(adminHostname)
|
want := strings.TrimSpace(adminHostname)
|
||||||
if want == "" {
|
if want == "" {
|
||||||
@@ -168,25 +173,41 @@ func hostIsAdminConsole(r *http.Request, rootDomain, adminHostname string) bool
|
|||||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||||
host = h
|
host = h
|
||||||
}
|
}
|
||||||
if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil {
|
if ip, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil {
|
||||||
return ip.IsLoopback() || ip.IsPrivate()
|
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, "."))
|
return strings.EqualFold(strings.TrimSuffix(host, "."), strings.TrimSuffix(want, "."))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionAuth is the composite ExternalAuth for the web face. It prefers a
|
// rootDomainIP is the address a wildcard-DNS root domain spells out:
|
||||||
// local session cookie and otherwise delegates to the remote JWT
|
// 10.0.0.5.nip.io and 10.0.0.5.sslip.io both name 10.0.0.5.
|
||||||
// verifier, so both auth models coexist on one face:
|
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
|
// - Cookie set → local auth MUST be enabled (a missing or non-true
|
||||||
// local_auth_enabled setting is treated as disabled — fail closed); the
|
// 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
|
// session hash must resolve to a live user.
|
||||||
// rejected and does NOT fall through to the JWT delegate, so a stale or
|
|
||||||
// forged cookie can never be laundered into a JWT attempt.
|
|
||||||
type SessionAuth struct {
|
type SessionAuth struct {
|
||||||
Repo Repo
|
Repo Repo
|
||||||
Delegate ExternalAuth
|
|
||||||
RootDomain string
|
RootDomain string
|
||||||
AdminHostname string
|
AdminHostname string
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -199,16 +220,12 @@ func (s SessionAuth) now() time.Time {
|
|||||||
return time.Now()
|
return time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate resolves the caller from a session cookie or delegates to the JWT
|
// Authenticate resolves the caller from the session cookie (see the type
|
||||||
// verifier (see the type comment for the fail-closed rules).
|
// comment for the fail-closed rules).
|
||||||
func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
|
func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
if err != nil || cookie.Value == "" {
|
if err != nil || cookie.Value == "" {
|
||||||
// No usable session cookie: this is the remote JWT path.
|
return nil, fmt.Errorf("no session")
|
||||||
if s.Delegate == nil {
|
|
||||||
return nil, fmt.Errorf("external auth not configured")
|
|
||||||
}
|
|
||||||
return s.Delegate.Authenticate(r)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
@@ -220,7 +237,7 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
|
|||||||
return nil, fmt.Errorf("%w: %v", errAuthBackend, err)
|
return nil, fmt.Errorf("%w: %v", errAuthBackend, err)
|
||||||
}
|
}
|
||||||
if !enabled {
|
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")
|
return nil, fmt.Errorf("local auth disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
// configuration the felis breakGlass TUI can offer a SysAdmin (spec §14 Zero
|
// configuration the felis breakGlass TUI can offer a SysAdmin (spec §14 Zero
|
||||||
// Trust edge). It is deliberately "锦上添花" — icing, not a mandate: the platform
|
// Trust edge). It is deliberately "锦上添花" — icing, not a mandate: the platform
|
||||||
// is domain-agnostic (every FQDN is composed from the configured root_domain) and
|
// 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
|
// IdP-agnostic (Access is enforced at the Cloudflare edge, whichever identity
|
||||||
// matter which identity provider — Google Workspace, Keycloak, Microsoft Entra —
|
// provider — Google Workspace, Keycloak, Microsoft Entra — fronts it). felis-api
|
||||||
// fronts it). A SysAdmin who brings their own domain or a different Zero-Trust
|
// 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.
|
// scheme is fully supported; this package only makes the common case easy.
|
||||||
//
|
//
|
||||||
// The split is honest about what this box can verify:
|
// 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
|
OnProgress func(string) // optional, called at each step for TUI display
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result reports what Setup produced, including the Access `aud` the caller must
|
// Result reports what Setup produced, including the Access `aud` the caller
|
||||||
// write into felis [auth] access_jwt_aud to make felis-api accept the new edge.
|
// records in felis [auth] access_jwt_aud (the behind-Cloudflare marker).
|
||||||
type Result struct {
|
type Result struct {
|
||||||
TunnelID string
|
TunnelID string
|
||||||
CredentialsFile string
|
CredentialsFile string
|
||||||
|
|||||||
@@ -114,8 +114,10 @@ type VelocityConfig struct {
|
|||||||
GamePort int `toml:"game_port"`
|
GamePort int `toml:"game_port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthConfig is the [auth] table: the two privileged faces and the access-JWT
|
// AuthConfig is the [auth] table: the two privileged faces and the Cloudflare
|
||||||
// audience the API enforces.
|
// 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 {
|
type AuthConfig struct {
|
||||||
AdminHostname string `toml:"admin_hostname"`
|
AdminHostname string `toml:"admin_hostname"`
|
||||||
PanelHostname string `toml:"panel_hostname"`
|
PanelHostname string `toml:"panel_hostname"`
|
||||||
|
|||||||
Reference in new issue
Block a user