diff --git a/cmd/felis/api.go b/cmd/felis/api.go index 0c6c322..c9b75a6 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -45,7 +45,7 @@ func authSourcesFromConfig(configured []config.AuthSourceConfig) []api.AuthSourc sources := make([]api.AuthSource, 0, len(configured)+1) sources = append(sources, api.AuthSource{Tag: "mojang", URL: mojangSessionServer, Identity: true}) for _, s := range configured { - sources = append(sources, api.AuthSource{Tag: s.Tag, URL: s.URL}) + sources = append(sources, api.AuthSource{Tag: s.Tag, Prefix: s.Prefix, URL: s.URL}) } return sources } diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 5af6d56..864642e 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -1178,8 +1178,15 @@ write_nano_config() { # Add each third-party Yggdrasil root below (priority = order). url is the FULL # hasJoined endpoint. After editing: sudo systemctl restart felis-nano # +# prefix is required, 1-4 letters/digits, unique per source. A player of this source +# whose name belongs to a Mojang account joins as PREFIX_name (LS_steve) instead — +# otherwise the proxy, which keys its player list on the NAME, refuses to have both +# online at once ("You are already connected to this proxy!"). Everyone else keeps +# their own name. +# # [[auth_source]] # tag = "littleskin" +# prefix = "LS" # url = "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined" EOF chmod 0644 "$target" diff --git a/internal/api/handlers_hasjoined.go b/internal/api/handlers_hasjoined.go index 5129ae9..05f0bd3 100644 --- a/internal/api/handlers_hasjoined.go +++ b/internal/api/handlers_hasjoined.go @@ -4,9 +4,13 @@ import ( "context" "encoding/hex" "encoding/json" + "fmt" "io" "net/http" "net/url" + "regexp" + "strings" + "sync" "time" "github.com/google/uuid" @@ -44,9 +48,12 @@ var authHTTPClient = &http.Client{Timeout: 5 * time.Second} // AuthSource is one upstream Yggdrasil root in the multiplexer's priority list (config // order = priority). URL is the full hasJoined endpoint the query string is appended to. // Identity marks the authoritative source (Mojang) whose UUIDs are trusted as-is; every -// other source is rewritten into felisAuthNS. +// other source is rewritten into felisAuthNS. Prefix is the in-game rename applied to a +// player of this source who is holding a Mojang player's name (see prefixedName); it is +// unused on the identity source, whose players are never renamed. type AuthSource struct { Tag string + Prefix string URL string Identity bool } @@ -102,7 +109,24 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) { } canonical = id } else { + // A third-party source is untrusted input, its name included: nothing stops a + // hostile or sloppy root from answering with "§4admin", an empty string, or 200 + // characters, all of which this handler would otherwise relay straight into the + // proxy's player list. + if !mcUsernameRe.MatchString(prof.Name) { + w.WriteHeader(http.StatusNoContent) + return + } canonical = uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+prof.ID)) + + // Give a Mojang player's name back to the Mojang player. The UUID rewrite above + // already keeps the two apart as identities, but the proxy's player registry is + // keyed on the NAME (Velocity: "You are already connected to this proxy!"), so + // without this they cannot even be online at the same time. Renaming only on an + // actual collision leaves the ordinary third-party player's name untouched. + if isPremiumName(r.Context(), prof.Name) { + prof.Name = prefixedName(src.Prefix, prof.Name) + } } // Bar gate at the single chokepoint every login crosses, so a reclaimed squatter @@ -123,6 +147,128 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, prof) } +// mcUsernameRe is Minecraft's username charset — the trust boundary on a third-party +// source's self-asserted profile name. +var mcUsernameRe = regexp.MustCompile(`^[A-Za-z0-9_]{3,16}$`) + +// mcUsernameMax is the protocol's username length ceiling, which prefixedName must respect. +const mcUsernameMax = 16 + +// prefixedName is the squatter rename: ("LS", "steve") → "LS_steve". The base name is +// TRUNCATED to fit rather than the rename being skipped when it would not fit — skipping is +// what would silently hand a 14-character premium name back to the squatter. +// +// ponytail: two players of one source whose names agree on their first mcUsernameMax-len(prefix)-1 +// characters truncate onto the same in-game name, as does a prefixed name that happens to be +// a premium name itself. Both cost an "already connected" bounce, not an identity: the UUID +// rewrite is what keeps players apart, and it does not depend on the name at all. Add a +// disambiguating suffix only if real players actually collide. +func prefixedName(prefix, name string) string { + p := prefix + "_" + if keep := mcUsernameMax - len(p); len(name) > keep { + name = name[:keep] + } + return p + name +} + +// mojangProfileAPI answers the one question that decides a rename: is this username +// registered to a Mojang account? A var, not a const, so a test can point it at a stub +// instead of the real Mojang. +var mojangProfileAPI = "https://api.mojang.com/users/profiles/minecraft/" + +// profileHTTPClient is deliberately more impatient than authHTTPClient: the name lookup is a +// SECOND Mojang round-trip on a third-party login (the identity leg already spent one), and +// api.mojang.com is exactly what is unreliable from the networks these servers sit on. A +// slow answer falls back to the cache instead of holding the login open. +var profileHTTPClient = &http.Client{Timeout: 2 * time.Second} + +// A name's premium status changes on human timescales, not per login, so it is cached — but +// asymmetrically, because the two directions have very different costs. "Taken" is nearly +// permanent (Mojang does not recycle names), while "free" can stop being true the moment +// someone buys that name, and a stale "free" is the dangerous one: it leaves a squatter +// holding a name its real owner has just bought. So a "free" answer is trusted for minutes +// and a "taken" answer for a day. +const ( + premiumTakenTTL = 24 * time.Hour + premiumFreeTTL = 10 * time.Minute + premiumCacheMax = 4096 +) + +type premiumEntry struct { + taken bool + at time.Time +} + +var premiumNames = struct { + sync.Mutex + m map[string]premiumEntry +}{m: make(map[string]premiumEntry)} + +// isPremiumName reports whether username belongs to a real Mojang account — which is what +// makes a third-party player holding it a squatter. On a lookup failure it prefers a stale +// cached answer, and with nothing cached it fails CLOSED (assume premium → rename the +// third-party player): a Mojang outage must not let a squatter keep a name the real owner is +// about to log in with. Being wrong that way costs a cosmetic prefix; being wrong the other +// way bounces the name's actual owner off the proxy. +func isPremiumName(ctx context.Context, username string) bool { + key := strings.ToLower(username) + + premiumNames.Lock() + cached, hit := premiumNames.m[key] + premiumNames.Unlock() + if hit && time.Since(cached.at) < premiumTTL(cached.taken) { + return cached.taken + } + + taken, err := lookupPremiumName(ctx, username) + if err != nil { + if hit { + return cached.taken + } + return true + } + + premiumNames.Lock() + // ponytail: bounded by dropping the whole map rather than evicting LRU — entries are + // only minted by players who actually authenticated somewhere, so this is a backstop + // against an unbounded map, not a cache policy worth tuning. + if len(premiumNames.m) >= premiumCacheMax { + clear(premiumNames.m) + } + premiumNames.m[key] = premiumEntry{taken: taken, at: time.Now()} + premiumNames.Unlock() + return taken +} + +func premiumTTL(taken bool) time.Duration { + if taken { + return premiumTakenTTL + } + return premiumFreeTTL +} + +// lookupPremiumName asks Mojang whether a name is registered: 200 = it is, 404 (204 on the +// legacy endpoint) = it is free. Anything else is an ERROR, never a "no" — a 429 or a 503 +// must not read as "this name is unowned"; see isPremiumName's fail-closed rule. +func lookupPremiumName(ctx context.Context, username string) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, mojangProfileAPI+url.PathEscape(username), nil) + if err != nil { + return false, err + } + resp, err := profileHTTPClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusNotFound, http.StatusNoContent: + return false, nil + } + return false, fmt.Errorf("mojang profile api: %s", resp.Status) +} + // resolveHasJoined queries each configured source in priority order and returns the // first that validates the session (200 with a profile). A source that is down, answers // non-200 (204 = "not my player"), or returns garbage is skipped. diff --git a/internal/api/handlers_hasjoined_test.go b/internal/api/handlers_hasjoined_test.go index dd671ed..60dafe7 100644 --- a/internal/api/handlers_hasjoined_test.go +++ b/internal/api/handlers_hasjoined_test.go @@ -5,11 +5,49 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "path" + "strings" "testing" "github.com/google/uuid" ) +// stubMojangNames points the premium-name lookup at a fake api.mojang.com that reports the +// given names as registered and every other name as free, and clears the process-wide cache +// so no subtest can see another's answers. Without it these tests would query the real +// Mojang over the network — and get a different answer on the day someone buys the name. +func stubMojangNames(t *testing.T, taken ...string) { + t.Helper() + registered := make(map[string]bool, len(taken)) + for _, n := range taken { + registered[strings.ToLower(n)] = true + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := strings.ToLower(path.Base(r.URL.Path)) + if !registered[name] { + w.WriteHeader(http.StatusNotFound) // Mojang's "nobody owns this name" + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"id": notchMojangID, "name": name}) + })) + t.Cleanup(srv.Close) + setProfileAPI(t, srv.URL+"/") +} + +func setProfileAPI(t *testing.T, base string) { + t.Helper() + prev := mojangProfileAPI + mojangProfileAPI = base + resetPremiumCache() + t.Cleanup(func() { mojangProfileAPI = prev; resetPremiumCache() }) +} + +func resetPremiumCache() { + premiumNames.Lock() + clear(premiumNames.m) + premiumNames.Unlock() +} + // fakeYgg stands in for one upstream Yggdrasil root. It answers hasJoined with the // given profile, or 204 when id == "" ("not my player") or a query field is missing — // the same contract Mojang's real sessionserver honors. @@ -74,9 +112,10 @@ func TestHasJoined(t *testing.T) { // and the reclaim/blacklist layer (keyed on "genuine Mojang has a different UUID") // could never catch it. t.Run("thirdparty UUID rewritten, never emitted as-is", func(t *testing.T) { + stubMojangNames(t) evil := fakeYgg(t, notchMojangID, "Notch") // lies: returns real Notch's Mojang UUID api := newTestAPI(newFakeRepo(), newFakeCluster()) - api.AuthSources = []AuthSource{{Tag: "littleskin", URL: evil.URL, Identity: false}} + api.AuthSources = []AuthSource{{Tag: "littleskin", Prefix: "LS", URL: evil.URL, Identity: false}} w := getHasJoined(api.InternalHandler(), "Notch", "abc") if w.Code != http.StatusOK { @@ -94,28 +133,36 @@ func TestHasJoined(t *testing.T) { // Mojang is priority-first: when both would validate the same name, Mojang wins. t.Run("mojang priority wins over thirdparty", func(t *testing.T) { + stubMojangNames(t, "Notch") mojang := fakeYgg(t, notchMojangID, "Notch") third := fakeYgg(t, "aaaaaaaaaaaa4aaaaaaaaaaaaaaaaaaa", "Notch") api := newTestAPI(newFakeRepo(), newFakeCluster()) api.AuthSources = []AuthSource{ {Tag: "mojang", URL: mojang.URL, Identity: true}, - {Tag: "littleskin", URL: third.URL, Identity: false}, + {Tag: "littleskin", Prefix: "LS", URL: third.URL, Identity: false}, } w := getHasJoined(api.InternalHandler(), "Notch", "abc") - if p := profileOf(t, w); p.ID != notchMojangID { + p := profileOf(t, w) + if p.ID != notchMojangID { t.Fatalf("id = %q, want mojang %q (priority)", p.ID, notchMojangID) } + // The player who OWNS the name is never the one who gets renamed, even though the + // name is (of course) a registered Mojang name. + if p.Name != "Notch" { + t.Fatalf("mojang name = %q, want unchanged %q", p.Name, "Notch") + } }) // Mojang doesn't know the player (204) → fall through to the third-party source, // whose profile is returned rewritten. t.Run("fallthrough to thirdparty when mojang 204s", func(t *testing.T) { + stubMojangNames(t) mojang := fakeYgg(t, "", "") // 204: not my player third := fakeYgg(t, notchMojangID, "Notch") api := newTestAPI(newFakeRepo(), newFakeCluster()) api.AuthSources = []AuthSource{ {Tag: "mojang", URL: mojang.URL, Identity: true}, - {Tag: "littleskin", URL: third.URL, Identity: false}, + {Tag: "littleskin", Prefix: "LS", URL: third.URL, Identity: false}, } w := getHasJoined(api.InternalHandler(), "Notch", "abc") if w.Code != http.StatusOK { @@ -145,12 +192,13 @@ func TestHasJoined(t *testing.T) { // reclaimed squatter stays out even on a consumer with no limbo plugin. Keyed on the // dashed canonical (post-rewrite), the same form Repo.ReclaimUsername stores. t.Run("barred canonical UUID -> 204", func(t *testing.T) { + stubMojangNames(t) third := fakeYgg(t, notchMojangID, "Notch") repo := newFakeRepo() canonical := uuid.NewMD5(felisAuthNS, []byte("littleskin:"+notchMojangID)) repo.blacklist[canonical.String()] = true // barred by a prior reclaim api := newTestAPI(repo, newFakeCluster()) - api.AuthSources = []AuthSource{{Tag: "littleskin", URL: third.URL, Identity: false}} + api.AuthSources = []AuthSource{{Tag: "littleskin", Prefix: "LS", URL: third.URL, Identity: false}} if w := getHasJoined(api.InternalHandler(), "Notch", "abc"); w.Code != http.StatusNoContent { t.Fatalf("barred login: code = %d, want 204", w.Code) @@ -167,3 +215,73 @@ func TestHasJoined(t *testing.T) { } }) } + +// The username namespace is the proxy's, not ours: Velocity keys its player registry on the +// NAME, so a third-party player holding a Mojang player's name locks the owner out of their +// own proxy ("You are already connected to this proxy!") even though the UUID rewrite makes +// them different players. These cover both branches of the rename — the rename itself, and +// the far more common case of leaving an ordinary player's name alone. +func TestHasJoinedPremiumNameRename(t *testing.T) { + thirdPartyLogin := func(t *testing.T, name string) sessionProfile { + t.Helper() + third := fakeYgg(t, notchMojangID, name) + api := newTestAPI(newFakeRepo(), newFakeCluster()) + api.AuthSources = []AuthSource{{Tag: "littleskin", Prefix: "LS", URL: third.URL, Identity: false}} + w := getHasJoined(api.InternalHandler(), name, "abc") + if w.Code != http.StatusOK { + t.Fatalf("code = %d, want 200 (%q)", w.Code, w.Body.String()) + } + return profileOf(t, w) + } + + // The branch the whole "only on collision" policy exists for: nobody at Mojang owns + // this name, so the third-party player keeps it. + t.Run("free name is left alone", func(t *testing.T) { + stubMojangNames(t, "Notch") // Notch is taken; Steve0 is not + if got := thirdPartyLogin(t, "Steve0").Name; got != "Steve0" { + t.Fatalf("name = %q, want unchanged %q — a player nobody collides with must keep their name", got, "Steve0") + } + }) + + t.Run("premium name is prefixed", func(t *testing.T) { + stubMojangNames(t, "Notch") + if got := thirdPartyLogin(t, "Notch").Name; got != "LS_Notch" { + t.Fatalf("name = %q, want %q", got, "LS_Notch") + } + }) + + // A name too long to prefix must be TRUNCATED, not left alone — skipping the rename + // here is what would silently hand a long premium name back to the squatter. + t.Run("long premium name is truncated to fit", func(t *testing.T) { + stubMojangNames(t, "Antidisestablish") // 16 chars: the protocol maximum + got := thirdPartyLogin(t, "Antidisestablish").Name + if got != "LS_Antidisestabl" { + t.Fatalf("name = %q, want %q", got, "LS_Antidisestabl") + } + if len(got) > mcUsernameMax { + t.Fatalf("name %q is %d chars, over the %d-char protocol limit", got, len(got), mcUsernameMax) + } + }) + + // Mojang unreachable, nothing cached → assume the name is premium and rename. A Mojang + // outage must not become an opportunity to hold someone else's name. + t.Run("mojang unreachable -> fails closed and renames", func(t *testing.T) { + setProfileAPI(t, "http://127.0.0.1:1/") // nothing listening: instant connection refused + if got := thirdPartyLogin(t, "Notch").Name; got != "LS_Notch" { + t.Fatalf("name = %q, want %q — a failed lookup must not leave a squatter holding the name", got, "LS_Notch") + } + }) + + // A third-party root is untrusted input, its name field included. + t.Run("hostile upstream name -> 204", func(t *testing.T) { + stubMojangNames(t) + for _, bad := range []string{"§4admin", "not a name", "ab", strings.Repeat("x", 17), ""} { + third := fakeYgg(t, notchMojangID, bad) + api := newTestAPI(newFakeRepo(), newFakeCluster()) + api.AuthSources = []AuthSource{{Tag: "littleskin", Prefix: "LS", URL: third.URL, Identity: false}} + if w := getHasJoined(api.InternalHandler(), "Notch", "abc"); w.Code != http.StatusNoContent { + t.Fatalf("upstream name %q: code = %d, want 204 (%q)", bad, w.Code, w.Body.String()) + } + } + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index ebfb2ee..e0dcfa2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ package config import ( "fmt" + "regexp" "strings" "github.com/BurntSushi/toml" @@ -36,10 +37,14 @@ type Config struct { // 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. +// Prefix is what a player from this source is renamed with when their name belongs to a +// Mojang player (LS_steve) — player-visible, so it is written out rather than derived from +// the tag, which cannot know that "littleskin" is meant to read LS. // No trusted/identity field, by design — see Config.AuthSources. type AuthSourceConfig struct { - Tag string `toml:"tag"` - URL string `toml:"url"` + Tag string `toml:"tag"` + Prefix string `toml:"prefix"` + URL string `toml:"url"` } // ServerConfig is the [server] table. @@ -286,15 +291,24 @@ func (c *Config) Validate() error { return c.validateAuthSources() } -// validateAuthSources checks the [[auth_source]] block: 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. -// Split out from Validate so the nano-only LoadNano (no control-plane fields) enforces the -// identical rules — the impersonation guard has one owner, shared by full-api and nano. +// authSourcePrefixRe is the shape of a prefix. It is prepended to a real Minecraft +// username (LS_steve), so it is confined to the username charset and kept short enough to +// leave a legible name behind after truncation. +var authSourcePrefixRe = regexp.MustCompile(`^[A-Za-z0-9]{1,4}$`) + +// validateAuthSources checks the [[auth_source]] block: each needs a namespace tag, a rename +// prefix, and a scheme-qualified hasJoined URL, and both tag and prefix 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 duplicate +// prefix collapses two same-named players from different sources onto one in-game name +// (they stay distinct identities, but neither can be online while the other is); a +// scheme-less URL makes http.NewRequest fail so the source is silently dead (never validates +// any login). All fail fast at load, not per-login. Split out from Validate so the nano-only +// LoadNano (no control-plane fields) enforces the identical rules — the impersonation guard +// has one owner, shared by full-api and nano. func (c *Config) validateAuthSources() error { seenTags := make(map[string]struct{}, len(c.AuthSources)) + seenPrefixes := 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) @@ -303,6 +317,16 @@ func (c *Config) validateAuthSources() error { 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 !authSourcePrefixRe.MatchString(s.Prefix) { + return fmt.Errorf(`config: [[auth_source]] %q needs prefix = "XX" (1-4 letters or digits, e.g. "LS" for LittleSkin), got %q; a player of this source whose name belongs to a Mojang account is renamed XX_name so the two can be online at once`, s.Tag, s.Prefix) + } + // Case-insensitively — the proxy's player registry folds case, so LS and ls would + // collide there even though they read as two different prefixes here. + lower := strings.ToLower(s.Prefix) + if _, dup := seenPrefixes[lower]; dup { + return fmt.Errorf("config: [[auth_source]] prefix %q is used twice — two sources sharing a prefix rewrite their same-named players onto the same in-game name", s.Prefix) + } + seenPrefixes[lower] = 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) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bbca9dd..94ea66c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -211,9 +211,11 @@ root_domain = "mc.example.net" url = "postgres://felis@db/felis" [[auth_source]] tag = "littleskin" +prefix = "LS" url = "https://littleskin.example.net/api/yggdrasil/sessionserver/session/minecraft/hasJoined" [[auth_source]] tag = "guild" +prefix = "GD" url = "https://guild.example.net/sessionserver/session/minecraft/hasJoined" `)) if err != nil { @@ -258,9 +260,11 @@ root_domain = "mc.example.net" url = "postgres://felis@db/felis" [[auth_source]] tag = "dup" +prefix = "AA" url = "https://a.example.net/hasJoined" [[auth_source]] tag = "dup" +prefix = "BB" url = "https://b.example.net/hasJoined" `)) if err == nil { @@ -283,6 +287,7 @@ root_domain = "mc.example.net" url = "postgres://felis@db/felis" [[auth_source]] tag = "bare" +prefix = "BR" url = "bare.example.net/hasJoined" `)) if err == nil { @@ -301,6 +306,7 @@ func TestLoadNanoAcceptsMinimalConfig(t *testing.T) { cfg, err := config.LoadNano(writeTOML(t, ` [[auth_source]] tag = "littleskin" +prefix = "LS" url = "https://littleskin.example.net/api/yggdrasil/sessionserver/session/minecraft/hasJoined" `)) if err != nil { @@ -321,9 +327,11 @@ func TestLoadNanoStillEnforcesAuthSourceRules(t *testing.T) { _, err := config.LoadNano(writeTOML(t, ` [[auth_source]] tag = "dup" +prefix = "AA" url = "https://a.example.net/hasJoined" [[auth_source]] tag = "dup" +prefix = "BB" url = "https://b.example.net/hasJoined" `)) if err == nil { @@ -334,6 +342,57 @@ url = "https://b.example.net/hasJoined" } } +// TestLoadRejectsBadAuthSourcePrefix pins the rename-prefix contract. The prefix is prepended +// to a real Minecraft username (LS_steve) when a third-party player is holding a Mojang +// player's name, so it must exist and must be legal there — a missing or illegal prefix would +// otherwise only surface as a login the proxy silently refuses, months later, the first time +// two players collide. +func TestLoadRejectsBadAuthSourcePrefix(t *testing.T) { + for name, prefix := range map[string]string{ + "missing": "", + "too long": "TOOLONG", + "underscore": "L_", + "non-ascii": "皮肤", + } { + t.Run(name, func(t *testing.T) { + _, err := config.LoadNano(writeTOML(t, ` +[[auth_source]] +tag = "littleskin" +prefix = "`+prefix+`" +url = "https://littleskin.example.net/hasJoined" +`)) + if err == nil { + t.Fatalf("expected error for prefix %q", prefix) + } + if !strings.Contains(err.Error(), "prefix") { + t.Errorf("error should name the prefix contract, got: %v", err) + } + }) + } +} + +// TestLoadRejectsDuplicateAuthSourcePrefix: two sources sharing a prefix rewrite their +// same-named players onto the SAME in-game name, which is the collision the prefix exists to +// break. Case-insensitively, because the proxy's player registry folds case. +func TestLoadRejectsDuplicateAuthSourcePrefix(t *testing.T) { + _, err := config.LoadNano(writeTOML(t, ` +[[auth_source]] +tag = "littleskin" +prefix = "LS" +url = "https://a.example.net/hasJoined" +[[auth_source]] +tag = "otherskin" +prefix = "ls" +url = "https://b.example.net/hasJoined" +`)) + if err == nil { + t.Fatal("expected LoadNano to reject two sources sharing a prefix (case-insensitively)") + } + if !strings.Contains(err.Error(), "prefix") { + t.Errorf("error should name the prefix contract, got: %v", err) + } +} + func TestLoadRejectsUnknownKeys(t *testing.T) { _, err := config.Load(writeTOML(t, ` [server]