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).
This commit is contained in:
3 files changed
+153
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user