feat(api): record account-link auth source (mojang|thirdparty)

Capture which Yggdrasil authenticated an in-game UUID when a link code is
minted (spec §10 dual-Yggdrasil) and copy it onto the durable account_links
row at verify. The value originates in-game — the web verify side never sees
the authentication — so it threads through account_link_codes, mirroring how
mc_uuid (not user_id) lives on a code.

- migration 0005: add link_auth_source enum + auth_source column on both
  account_link_codes and account_links; DEFAULT 'mojang' backfills existing
  rows and sets the Mojang-priority default for a mint that omits the field
- mint validates an explicit auth_source (unknown value -> 400); verify
  surfaces it in the 200 body and refreshes it on idempotent re-verify
This commit is contained in:
flyemoji committed 2026-06-27 13:01:19 +09:00
1 parent dbe34a175f
commit 1f8b9bb5d0
7 files changed
+179 -48

No files matched your search

+38 -5
View File
@@ -34,8 +34,23 @@ const (
// linkCodeLen is the symbol count: a 32^8 ≈ 1.1e12 keyspace, far beyond brute
// force inside the TTL.
linkCodeLen = 8
// authSource records which Yggdrasil established the in-game UUID when a code
// was minted (spec §10, dual-Yggdrasil): the official Mojang service, or a
// configured thirdparty. It is captured at mint (the only place that knows it)
// and copied onto the durable link at verify; the web side never sees the
// authentication. These mirror the link_auth_source enum (migration 0005).
authSourceMojang = "mojang"
authSourceThirdParty = "thirdparty"
)
// validAuthSource reports whether s is a recognised link_auth_source value. An
// empty string is NOT valid here — handleCreateLinkCode defaults it before this
// check, so a non-empty value reaching validation must be one we can store.
func validAuthSource(s string) bool {
return s == authSourceMojang || s == authSourceThirdParty
}
// newLinkCode returns a cryptographically random, unambiguous link code.
func newLinkCode() (string, error) {
buf := make([]byte, linkCodeLen)
@@ -49,9 +64,13 @@ func newLinkCode() (string, error) {
}
// createLinkCodeRequest is the in-game /link callback body (spec §10): the
// backend reports the verified UUID of the player who ran the command.
// backend reports the verified UUID of the player who ran the command, plus how
// that UUID was authenticated (auth_source). auth_source is optional — an older
// backend that omits it falls back to the Mojang-priority default — but a value
// that IS sent must be one we can store.
type createLinkCodeRequest struct {
MCUUID string `json:"mc_uuid"`
MCUUID string `json:"mc_uuid"`
AuthSource string `json:"auth_source"`
}
// handleCreateLinkCode mints a one-time link code for a verified in-game UUID
@@ -69,13 +88,25 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
return
}
// Default an omitted source to Mojang (spec §10 priority) but reject an
// unrecognised one — a typo'd source must not silently land as a stored value
// the panel will later mislabel.
authSource := req.AuthSource
if authSource == "" {
authSource = authSourceMojang
}
if !validAuthSource(authSource) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"auth_source must be %q or %q", authSourceMojang, authSourceThirdParty))
return
}
code, err := newLinkCode()
if err != nil {
writeError(w, r, err)
return
}
expiresAt := a.now().Add(linkCodeTTL)
if err := a.Repo.CreateLinkCode(r.Context(), code, req.MCUUID, expiresAt); err != nil {
if err := a.Repo.CreateLinkCode(r.Context(), code, req.MCUUID, authSource, expiresAt); err != nil {
writeError(w, r, err)
return
}
@@ -110,7 +141,7 @@ func (a *API) handleLinkVerify(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
return
}
mcUUID, err := a.Repo.VerifyLinkCode(r.Context(), p.UserID, code, a.now())
mcUUID, authSource, err := a.Repo.VerifyLinkCode(r.Context(), p.UserID, code, a.now())
switch {
case errors.Is(err, ErrLinkCodeInvalid):
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "link code is invalid or expired"))
@@ -124,7 +155,9 @@ func (a *API) handleLinkVerify(w http.ResponseWriter, r *http.Request) {
return
}
a.audit(r, p.Email, "account.link", "")
writeJSON(w, http.StatusOK, map[string]any{"linked": true, "mc_uuid": mcUUID})
writeJSON(w, http.StatusOK, map[string]any{
"linked": true, "mc_uuid": mcUUID, "auth_source": authSource,
})
}
// handleLinkStart reports the caller's link status and how to link (spec §10,