Unverified Commit 4839d52f authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(api): passkey 的 RP ID 按面板主机推导,只配 root_domain 的部署也能用 passkey

parent e9e9a7a6
Loading
Loading
Loading
Loading
+22 −13
Changes for cmd/felis/api.go: 22 added lines, 13 removed lines.
Original line number Diff line number Diff line
@@ -49,6 +49,22 @@ import (
// the code marks Identity (UUIDs trusted verbatim); config can never add another.
const mojangSessionServer = "https://sessionserver.mojang.com/session/minecraft/hasJoined"

// passkeyRelyingParty is the one WebAuthn relying party both web faces share: its id
// is the player console host, derived from server.root_domain the way the panel
// handler derives it when auth.panel_hostname is unset, and its origins are that host
// plus the operator host. An empty id means the install names no panel host at all.
func passkeyRelyingParty(cfg *config.Config) (string, []string) {
	rpID := defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname)
	if rpID == "" {
		return "", nil
	}
	origins := []string{"https://" + rpID}
	if admin := defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname); admin != "" && admin != rpID {
		origins = append(origins, "https://"+admin)
	}
	return rpID, origins
}

// authSourcesFromConfig builds the multiplexer's priority list from the configured
// [[auth_source]] entries: Mojang leads as the code-owned identity anchor (正版优先, the ONLY
// Identity source — config can only append namespace-rewritten third-party sources, never a
@@ -393,23 +409,16 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
	// enrolled once asserts on either face — one binding, usable on the player console
	// AND the operator console. Both hosts are therefore listed as permitted origins,
	// while the RP id stays the panel host so the credential's scope is ONE relying
	// party, not two. Wired only when auth.panel_hostname is configured; otherwise
	// a.Passkey stays nil and the passkey routes honestly return 503 (the authenticated
	// enrollment boundary is still enforced by the handlers).
	if cfg.Auth.PanelHostname != "" {
		origins := []string{"https://" + cfg.Auth.PanelHostname}
		if admin := defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname); admin != "" && admin != cfg.Auth.PanelHostname {
			origins = append(origins, "https://"+admin)
		}
		pv, err := passkey.New(cfg.Auth.PanelHostname, "Felis", origins)
		if err != nil {
	// party, not two. Without a panel host (neither auth.panel_hostname nor
	// server.root_domain) a.Passkey stays nil and the passkey routes honestly return
	// 503 (the authenticated enrollment boundary is still enforced by the handlers).
	if rpID, origins := passkeyRelyingParty(cfg); rpID == "" {
		fmt.Fprintln(stderr, "felis api: passkey verifier disabled (no panel host: set server.root_domain or auth.panel_hostname) — passkey endpoints return 503")
	} else if pv, err := passkey.New(rpID, "Felis", origins); err != nil {
		fmt.Fprintf(stderr, "felis api: passkey verifier disabled: %v — passkey endpoints return 503\n", err)
	} else {
		a.Passkey = pv
	}
	} else {
		fmt.Fprintln(stderr, "felis api: passkey verifier disabled (auth.panel_hostname unset) — passkey endpoints return 503")
	}

	// Derive the console hostnames when felis.toml leaves them unset, exactly as the
	// setup/breakGlass paths do — otherwise the SPA cannot tell which face it is
+32 −0
Changes for cmd/felis/api_test.go: 32 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -7,6 +7,7 @@ import (
	"io"
	"net"
	"net/http"
	"slices"
	"sync/atomic"
	"testing"
	"time"
@@ -16,6 +17,37 @@ import (
	"felis.lolicon.best/internal/config"
)

// The passkey relying party follows the panel host the SPA is served on: an install
// that names only its root domain still gets passkeys, on console.<root>, with the
// operator host as the second origin; only an install with no panel host goes without.
func TestPasskeyRelyingParty(t *testing.T) {
	for _, tc := range []struct {
		name               string
		root, panel, admin string
		wantRP             string
		wantOrigins        []string
	}{
		{"root domain only", "example.net", "", "", "console.example.net",
			[]string{"https://console.example.net", "https://op.console.example.net"}},
		{"configured hosts", "example.net", " play.example.net ", "ops.example.net", "play.example.net",
			[]string{"https://play.example.net", "https://ops.example.net"}},
		{"operator host equal to the panel host", "example.net", "console.example.net", "console.example.net",
			"console.example.net", []string{"https://console.example.net"}},
		{"panel host without a root domain", "", "console.example.org", "", "console.example.org",
			[]string{"https://console.example.org"}},
		{"no host at all", "", "", "", "", nil},
	} {
		t.Run(tc.name, func(t *testing.T) {
			cfg := &config.Config{}
			cfg.Server.RootDomain, cfg.Auth.PanelHostname, cfg.Auth.AdminHostname = tc.root, tc.panel, tc.admin
			rp, origins := passkeyRelyingParty(cfg)
			if rp != tc.wantRP || !slices.Equal(origins, tc.wantOrigins) {
				t.Fatalf("relying party = %q %q, want %q %q", rp, origins, tc.wantRP, tc.wantOrigins)
			}
		})
	}
}

// TestAuthSourcesFromConfig pins the one place the hasJoined identity anchor is decided:
// Mojang is prepended in code, first, and is the only source whose UUIDs are trusted as-is.
// The empty case matters on its own — both `felis api` and `felis nano` call this with a