Unverified Commit ecea20ee authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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).
parent ed8fa0cd
Loading
Loading
Loading
Loading
+21 −0
Changes for cmd/felis/api.go: 21 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+38 −0
Changes for internal/config/config.go: 38 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
}
+94 −0
Changes for internal/config/config_test.go: 94 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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]