From ecea20ee7c499b0202b6314b0cd5f2b94bd4276f Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Sun, 12 Jul 2026 02:27:02 +0900 Subject: [PATCH] feat(nano): configure hasJoined auth sources via [[auth_source]], Mojang-anchored Step 2 of Felis-nano: a [[auth_source]] array-of-tables (tag + full hasJoined url, config order = priority) supplies the multiplexer's third-party Yggdrasil roots; cmd/felis prepends Mojang as the sole code-owned identity anchor and wires them into API.AuthSources. With no sources configured the endpoint stays inert (204s), unchanged from step 1. The config deliberately has no identity/trusted field: Mojang is the only source whose self-asserted UUIDs are trusted verbatim, so no misconfiguration can reopen the impersonation hole the per-source UUID rewrite closes. An identity= key is an unknown key and Load rejects it. Validate adds two fail-fast guards: unique tags (namespace collision) and a scheme-qualified url (else the source is silently dead, never validating any login). --- cmd/felis/api.go | 21 ++++++++ internal/config/config.go | 38 ++++++++++++++ internal/config/config_test.go | 94 ++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) diff --git a/cmd/felis/api.go b/cmd/felis/api.go index 4e50edb..f0b4ef0 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -30,6 +30,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) +// mojangSessionServer is the public Mojang hasJoined endpoint the Felis-nano multiplexer +// leads with as its code-owned identity anchor (正版优先). A protocol constant, not a +// deployment domain, so it is hardcoded rather than configured — and it is the ONLY source +// the code marks Identity (UUIDs trusted verbatim); config can never add another. +const mojangSessionServer = "https://sessionserver.mojang.com/session/minecraft/hasJoined" + // cmdAPI runs felis-api: two listeners, two middleware chains (spec §7). The // internal face (service token) is fully wired. The external face is wired but // fails closed until an Access JWKS key function is configured — the verifier's @@ -216,6 +222,21 @@ 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 { + sources := make([]api.AuthSource, 0, len(cfg.AuthSources)+1) + sources = append(sources, api.AuthSource{Tag: "mojang", URL: mojangSessionServer, Identity: true}) + for _, s := range cfg.AuthSources { + sources = append(sources, api.AuthSource{Tag: s.Tag, URL: s.URL}) + } + a.AuthSources = sources + fmt.Fprintf(stderr, "felis api: hasJoined multiplexer active — Mojang + %d third-party source(s)\n", len(cfg.AuthSources)) + } + // Passkey (WebAuthn) enrollment verifier (spec §14, Phase 6). The relying party is // the panel (app) face: the RP id is the panel hostname and the single permitted // origin is that host over https, so a credential enrolled here is scoped to the diff --git a/internal/config/config.go b/internal/config/config.go index cb11dd4..58df864 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,26 @@ type Config struct { K8s K8sConfig `toml:"k8s"` Registry RegistryConfig `toml:"registry"` Archive ArchiveConfig `toml:"archive"` + // AuthSources is the [[auth_source]] array-of-tables: the third-party Yggdrasil + // roots the Felis-nano hasJoined multiplexer federates over, in priority order + // (config order = priority, so array-of-tables not a map — a map would lose order + // and silently break Mojang-first). Empty = the multiplexer ships off. There is + // deliberately NO identity/trusted field here: Mojang is the single code-owned + // identity anchor (cmd/felis prepends it) and every configured source is + // namespace-rewritten, so no config can mint a source whose self-asserted UUIDs are + // trusted verbatim — the impersonation hole that rewrite closes cannot be reopened by + // misconfiguration. (An `identity =` key here is an unknown key → Load rejects it.) + AuthSources []AuthSourceConfig `toml:"auth_source"` +} + +// AuthSourceConfig is one [[auth_source]] entry: a third-party Yggdrasil root the +// Felis-nano multiplexer federates over. Tag names the source's per-source UUID +// namespace (must be unique — two sources sharing a tag would collide onto one identity); +// URL is the full hasJoined endpoint (scheme-qualified) the query string is appended to. +// No trusted/identity field, by design — see Config.AuthSources. +type AuthSourceConfig struct { + Tag string `toml:"tag"` + URL string `toml:"url"` } // ServerConfig is the [server] table. @@ -231,5 +251,23 @@ func (c *Config) Validate() error { if c.Registry.URL != "" && strings.Contains(c.Registry.URL, "://") { return fmt.Errorf("config: [registry] url %q must be a bare host[:port] with no scheme (e.g. registry.felis.svc:5000); a scheme breaks the user-modpack build lane's derived push target", c.Registry.URL) } + // Felis-nano auth sources: each needs a namespace tag and a scheme-qualified hasJoined + // URL, and tags must be unique. A blank or duplicate tag collapses two sources into one + // UUID namespace (cross-source impersonation — the exact invariant the per-source + // rewrite exists to hold); a scheme-less URL makes http.NewRequest fail so the source is + // silently dead (never validates any login). Both fail fast at load, not per-login. + seenTags := make(map[string]struct{}, len(c.AuthSources)) + for i, s := range c.AuthSources { + if s.Tag == "" { + return fmt.Errorf("config: [[auth_source]] #%d has an empty tag; each source's tag is its per-source UUID namespace", i+1) + } + if _, dup := seenTags[s.Tag]; dup { + return fmt.Errorf("config: [[auth_source]] tag %q is used twice — tags are per-source UUID namespaces and must be unique", s.Tag) + } + seenTags[s.Tag] = struct{}{} + if !strings.HasPrefix(s.URL, "http://") && !strings.HasPrefix(s.URL, "https://") { + return fmt.Errorf("config: [[auth_source]] %q url %q must be a scheme-qualified http(s):// hasJoined endpoint", s.Tag, s.URL) + } + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6049a92..19e75f8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -199,6 +199,100 @@ url = "`+url+`" } } +// TestLoadAuthSourcesPreservesOrder pins the Felis-nano priority contract: the +// [[auth_source]] array-of-tables decodes in file order (config order = priority), which +// is why it is an array-of-tables and not a map. A map keyed by tag would load and pass +// this file yet silently reorder the sources, breaking Mojang-first federation. +func TestLoadAuthSourcesPreservesOrder(t *testing.T) { + cfg, err := config.Load(writeTOML(t, ` +[server] +root_domain = "mc.example.net" +[database] +url = "postgres://felis@db/felis" +[[auth_source]] +tag = "littleskin" +url = "https://littleskin.example.net/api/yggdrasil/sessionserver/session/minecraft/hasJoined" +[[auth_source]] +tag = "guild" +url = "https://guild.example.net/sessionserver/session/minecraft/hasJoined" +`)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.AuthSources) != 2 { + t.Fatalf("auth sources = %d, want 2", len(cfg.AuthSources)) + } + if cfg.AuthSources[0].Tag != "littleskin" || cfg.AuthSources[1].Tag != "guild" { + t.Errorf("source order = %q,%q, want littleskin,guild", cfg.AuthSources[0].Tag, cfg.AuthSources[1].Tag) + } +} + +// TestLoadRejectsAuthSourceIdentityKey guards the crown-jewel invariant structurally: there +// is no identity/trusted field on AuthSourceConfig, so an attempt to set one is an unknown +// key and Load rejects it loudly. A config can therefore never mint a source whose +// self-asserted UUIDs are trusted verbatim — the impersonation hole stays closed. +func TestLoadRejectsAuthSourceIdentityKey(t *testing.T) { + _, err := config.Load(writeTOML(t, ` +[server] +root_domain = "mc.example.net" +[database] +url = "postgres://felis@db/felis" +[[auth_source]] +tag = "evil" +url = "https://evil.example.net/hasJoined" +identity = true +`)) + if err == nil { + t.Fatal("expected error for an identity= key on [[auth_source]]") + } +} + +// TestLoadRejectsDuplicateAuthSourceTag pins the namespace-collision guard: two sources +// sharing a tag would collapse into one per-source UUID namespace, reopening cross-source +// impersonation. Must be rejected at load. +func TestLoadRejectsDuplicateAuthSourceTag(t *testing.T) { + _, err := config.Load(writeTOML(t, ` +[server] +root_domain = "mc.example.net" +[database] +url = "postgres://felis@db/felis" +[[auth_source]] +tag = "dup" +url = "https://a.example.net/hasJoined" +[[auth_source]] +tag = "dup" +url = "https://b.example.net/hasJoined" +`)) + if err == nil { + t.Fatal("expected error for duplicate auth_source tag") + } + if !strings.Contains(err.Error(), "unique") { + t.Errorf("error should explain the tags-must-be-unique contract, got: %v", err) + } +} + +// TestLoadRejectsSchemelessAuthSourceURL pins the silently-dead-source guard: a URL with no +// http(s):// scheme makes http.NewRequest fail, so the source never validates any login yet +// felis-api boots green. Reject at load with the scheme contract spelled out. An empty tag +// is caught by the same loop. +func TestLoadRejectsSchemelessAuthSourceURL(t *testing.T) { + _, err := config.Load(writeTOML(t, ` +[server] +root_domain = "mc.example.net" +[database] +url = "postgres://felis@db/felis" +[[auth_source]] +tag = "bare" +url = "bare.example.net/hasJoined" +`)) + if err == nil { + t.Fatal("expected error for schemeless auth_source url") + } + if !strings.Contains(err.Error(), "scheme") { + t.Errorf("error should explain the scheme contract, got: %v", err) + } +} + func TestLoadRejectsUnknownKeys(t *testing.T) { _, err := config.Load(writeTOML(t, ` [server]