docs(nano): describe the hasjoined path as it works

The comments around hasJoined still described an authlib client that
is not in the path. Velocity reads -Dmojang.sessionserver and sends
the request itself, and it turns a 204 into its online-mode-only kick,
not authlib's "failed to verify username". The route comment in api.go
also offered "a thin login hook" as an alternative that does not
exist.

Other comments had drifted from the code:

- The [[auth_source]] doc said an empty list ships the multiplexer
  off. Mojang is always prepended, so an empty list means Mojang is
  the only source.
- The premium-name cache said Mojang does not recycle names. A name
  frees up when its owner renames away. The day-long "taken" TTL still
  holds, because a stale "taken" costs a third-party player only a
  prefix.
- The cache bound claimed entries come only from players who
  authenticated somewhere. Any third-party source that validates a
  login adds one, so a hostile source can force the map to clear. That
  costs repeat lookups, or a fail-closed prefix while Mojang is
  unreachable, never an identity.

The rewrite rationale now states what it costs a backend operator. A
chat-session key that a third-party source signed over its native UUID
cannot verify against the canonical UUID, so chat from those players
can only be accepted unsigned.

In the tests, comments that repeated their subtest names are gone.
This commit is contained in:
flyemoji committed 2026-09-22 13:42:03 +09:00
1 parent 59ec23d4a8
commit 1ebd73a309
4 files changed
+31 -31

No files matched your search

+6 -6
View File
@@ -303,12 +303,12 @@ func (a *API) internalAPIRoutes() []apiRoute {
// Mojang player (same name, different UUID) always passes.
{Method: "POST", Pattern: "/api/v1/internal/player/reclaim", h: a.handleReclaimUsername},
{Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", h: a.handleCheckBlacklist},
// Felis-nano multi-source session verifier (spec §B3 player game-login).
// Velocity's authlib is pointed here (-Dmojang.sessionserver or a thin login
// hook); it speaks the vanilla sessionserver protocol and carries no token, so
// this is Public. It fans hasJoined out to the configured Yggdrasil roots
// (Mojang-first) and rewrites third-party UUIDs into a per-source namespace
// before returning the canonical profile (handlers_hasjoined.go).
// Felis-nano multi-source session verifier, behind player game-login. Velocity is
// pointed here with -Dmojang.sessionserver and issues the request itself; it speaks
// the vanilla sessionserver protocol and carries no token, so this is Public. It
// fans hasJoined out to the configured Yggdrasil roots (Mojang-first) and rewrites
// third-party UUIDs into a per-source namespace before returning the canonical
// profile (handlers_hasjoined.go).
{Method: "GET", Pattern: "/session/minecraft/hasJoined", Public: true, h: a.handleHasJoined},
// Op-login (passwordless op.console login): a staff member starts the login
// on the web, and an ONLINE in-game admin vouches for it via velocity's
+23 -18
View File
@@ -17,14 +17,14 @@ import (
"github.com/google/uuid"
)
// Felis-nano: the multi-source hasJoined multiplexer (spec §B3 player game-login).
// Felis-nano: the multi-source hasJoined multiplexer behind player game-login.
//
// Velocity's session verifier (authlib) is pointed here — via -Dmojang.sessionserver
// on Felis-managed proxies, or a thin login-pipeline hook on third-party servers. On
// login Velocity computes the serverId hash and GETs hasJoined; this endpoint fans that
// query out to the configured Yggdrasil roots in priority order (Mojang first, 正版优先)
// and returns the first source that validates. Each upstream Yggdrasil runs its own
// serverId-hash check — the multiplexer only relays, it computes no hashes.
// Velocity is pointed here with -Dmojang.sessionserver and issues the request itself, not
// through authlib. On login it computes the serverId hash and GETs hasJoined; this
// endpoint fans that query out to the configured Yggdrasil roots in priority order
// (Mojang first, 正版优先) and returns the first source that validates. Each upstream
// Yggdrasil runs its own serverId-hash check — the multiplexer only relays, it computes
// no hashes.
//
// The one non-negotiable transform: a non-identity (third-party) source's UUID is
// self-asserted, so its profile is rewritten into a per-source namespace
@@ -34,6 +34,10 @@ import (
// preimage resistance means no third-party source can mint a Mojang-space UUID, and the
// per-tag namespace means two sources cannot collide onto one identity. Every downstream
// key (account_links, username_blacklist, owner checks) then sees one canonical UUID.
//
// One consequence a backend operator meets: a chat-session key a third-party source signed
// over its native UUID cannot verify against the canonical one, even on a backend that
// trusts that source's key. Such players' chat can only be accepted unsigned.
// felisAuthNS is the fixed UUIDv3 namespace every third-party profile is rewritten
// under (see the rewrite rationale above). Derived from the project name, not a magic
@@ -102,9 +106,9 @@ func HasJoinedHandler(sources []AuthSource, repo Repo) http.Handler {
}
// handleHasJoined is the multi-source session verifier (Felis-nano). It is a Public
// internal-face route: authlib speaks the vanilla sessionserver protocol and sends no
// internal-face route: Velocity speaks the vanilla sessionserver protocol and sends no
// service token. A rejected login is 204 No Content — exactly what Mojang returns for an
// invalid session, which authlib maps to "failed to verify username".
// invalid session, which Velocity answers with its online-mode-only kick.
func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
// Velocity sends no body. When a request declares one anyway, net/http tries to drain it
// before writing any answer, so one that never arrives holds the connection with no
@@ -181,7 +185,7 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
return
}
// Emit the canonical UUID undashed — the 32-hex form authlib's GameProfile expects.
// Emit the canonical UUID undashed — the 32-hex form Velocity's GameProfile expects.
prof.ID = hex.EncodeToString(canonical[:])
if prof.Properties == nil {
prof.Properties = []json.RawMessage{} // a nil slice would marshal as null
@@ -230,11 +234,11 @@ var mojangProfileAPI = "https://api.mojang.com/users/profiles/minecraft/"
var profileHTTPClient = &http.Client{Timeout: 2 * time.Second, Transport: upstreamTransport}
// 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.
// asymmetrically, because the two directions have very different costs. "Taken" changes
// only when its owner renames away, and a stale "taken" costs a third-party player nothing
// but a prefix. "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
@@ -276,9 +280,10 @@ func isPremiumName(ctx context.Context, username string) bool {
}
premiumNames.Lock()
// 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.
// Bounded by dropping the whole map rather than evicting LRU. Any third-party source that
// validates a login mints an entry, so a hostile one can force clears; that costs repeat
// lookups, or a fail-closed prefix while Mojang is unreachable, never an identity. This
// is a backstop against an unbounded map, not a cache policy worth tuning.
if len(premiumNames.m) >= premiumCacheMax {
clear(premiumNames.m)
}
+1 -6
View File
@@ -96,13 +96,12 @@ func profileOf(t *testing.T, w *httptest.ResponseRecorder) sessionProfile {
return p
}
// undashed is the 32-hex form the resolver must emit (authlib's GameProfile format).
// undashed is the 32-hex form the resolver must emit (Velocity's GameProfile format).
func undashed(u uuid.UUID) string { return hex.EncodeToString(u[:]) }
const notchMojangID = "069a79f444e94726a5befca90e38aaf5" // a real Mojang-space UUID, undashed
func TestHasJoined(t *testing.T) {
// A trusted (Mojang) source passes its UUID through byte-for-byte.
t.Run("mojang identity passthrough", func(t *testing.T) {
mojang := fakeYgg(t, notchMojangID, "Notch")
api := newTestAPI(newFakeRepo(), newFakeCluster())
@@ -152,7 +151,6 @@ 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")
@@ -174,8 +172,6 @@ func TestHasJoined(t *testing.T) {
}
})
// 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
@@ -195,7 +191,6 @@ func TestHasJoined(t *testing.T) {
}
})
// No source validates → 204 (authlib maps this to a verify failure).
t.Run("no source validates -> 204", func(t *testing.T) {
a := fakeYgg(t, "", "")
b := fakeYgg(t, "", "")
+1 -1
View File
@@ -26,7 +26,7 @@ type Config struct {
// 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
// and silently break Mojang-first). Empty = Mojang is the only source. 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