diff --git a/cmd/felis/api.go b/cmd/felis/api.go index 1c5623b..607a245 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -286,15 +286,14 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { } fmt.Fprintln(stderr, "felis api: external face fails closed (Access JWKS key function not configured)") - // Felis-nano: wire the multi-source hasJoined multiplexer only when third-party auth - // sources are configured. Mojang leads as the code-owned identity anchor (正版优先); - // config can only append namespace-rewritten third-party sources, never a trusted one, - // so a misconfig cannot reopen the impersonation hole. No sources = a.AuthSources stays - // nil = the endpoint 204s every login (ships off). - if len(cfg.AuthSources) > 0 { - a.AuthSources = authSourcesFromConfig(cfg.AuthSources) - fmt.Fprintf(stderr, "felis api: hasJoined multiplexer active — Mojang + %d third-party source(s)\n", len(cfg.AuthSources)) - } + // Felis-nano: the multi-source hasJoined multiplexer. Mojang leads as the code-owned + // identity anchor (正版优先); config can only append namespace-rewritten third-party + // sources, never a trusted one, so a misconfig cannot reopen the impersonation hole. + // Wired unconditionally: the installer points Velocity at this route whether or not any + // [[auth_source]] is configured, so an empty list has to mean a Mojang-only relay, the + // same as under `felis nano`. A nil list would 204 every login, premium ones included. + a.AuthSources = authSourcesFromConfig(cfg.AuthSources) + fmt.Fprintf(stderr, "felis api: hasJoined multiplexer active — Mojang + %d third-party source(s)\n", len(cfg.AuthSources)) // Passkey (WebAuthn) verifier (spec §14, Phase 6). One relying party spans BOTH // web faces: the RP id is the panel hostname (console.), and because that is diff --git a/cmd/felis/api_test.go b/cmd/felis/api_test.go index 7b6c7a3..755489e 100644 --- a/cmd/felis/api_test.go +++ b/cmd/felis/api_test.go @@ -3,8 +3,47 @@ package main import ( "net/http" "testing" + + "felis.lolicon.best/internal/config" ) +// 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 +// config that has no [[auth_source]] at all, and that has to be a Mojang-only relay rather +// than an empty list that rejects every login. +func TestAuthSourcesFromConfig(t *testing.T) { + for _, tc := range []struct { + name string + configured []config.AuthSourceConfig + }{ + {"no configured sources", nil}, + {"configured sources", []config.AuthSourceConfig{ + {Tag: "littleskin", Prefix: "LS", URL: "https://littleskin.example/hasJoined"}, + {Tag: "guild", Prefix: "GD", URL: "https://guild.example/hasJoined"}, + }}, + } { + t.Run(tc.name, func(t *testing.T) { + got := authSourcesFromConfig(tc.configured) + if len(got) != len(tc.configured)+1 { + t.Fatalf("got %d sources, want Mojang + %d configured", len(got), len(tc.configured)) + } + if got[0].Tag != "mojang" || got[0].URL != mojangSessionServer || !got[0].Identity { + t.Errorf("first source = %+v, want the Mojang identity anchor", got[0]) + } + for i, c := range tc.configured { + s := got[i+1] + if s.Identity { + t.Errorf("configured source %q is marked Identity; only Mojang may be", c.Tag) + } + if s.Tag != c.Tag || s.Prefix != c.Prefix || s.URL != c.URL { + t.Errorf("source %d = %+v, want %+v in config order", i+1, s, c) + } + } + }) + } +} + // TestNewAPIServerSetsHardenedTimeouts pins the gosec-G112 hardening on every // felis-api listener: the shared factory must bound the header and idle phases // (Slowloris + idle-connection exhaustion) while leaving WriteTimeout UNSET, because diff --git a/internal/api/api.go b/internal/api/api.go index 3170464..0dcc56a 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -139,9 +139,9 @@ type API struct { // AuthSources is the Felis-nano multi-source hasJoined multiplexer's upstream // Yggdrasil list, in priority order (config order; the Mojang Identity source - // first for 正版优先). Nil — the default — makes the session verifier reject every - // login (204), so the endpoint ships inert until cmd/felis wires configured - // sources. Consumed by handleHasJoined (handlers_hasjoined.go). + // first for 正版优先). Nil makes the session verifier reject every login (204); + // cmd/felis always wires at least the Mojang source through authSourcesFromConfig. + // Consumed by handleHasJoined (handlers_hasjoined.go). AuthSources []AuthSource // Now is the clock, injectable for tests. Defaults to time.Now.