Loading cmd/felis/api.go +22 −13 Changes for cmd/felis/api.go: 22 added lines, 13 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading cmd/felis/api_test.go +32 −0 Changes for cmd/felis/api_test.go: 32 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -7,6 +7,7 @@ import ( "io" "net" "net/http" "slices" "sync/atomic" "testing" "time" Loading @@ -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 Loading Loading
cmd/felis/api.go +22 −13 Changes for cmd/felis/api.go: 22 added lines, 13 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading
cmd/felis/api_test.go +32 −0 Changes for cmd/felis/api_test.go: 32 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -7,6 +7,7 @@ import ( "io" "net" "net/http" "slices" "sync/atomic" "testing" "time" Loading @@ -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 Loading