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
+172 -41

No files matched your search

+14 -1
View File
@@ -643,6 +643,15 @@ paths:
required: [mc_uuid]
properties:
mc_uuid: { type: string }
auth_source:
type: string
enum: [mojang, thirdparty]
default: mojang
description: >
Which Yggdrasil authenticated the in-game UUID (spec §10
dual-Yggdrasil). Optional; an omitted value defaults to the
Mojang-priority source. Captured here because only the in-game
side sees the authentication; it is copied onto the link at verify.
responses:
'201':
description: Code minted.
@@ -1427,10 +1436,14 @@ paths:
application/json:
schema:
type: object
required: [linked, mc_uuid]
required: [linked, mc_uuid, auth_source]
properties:
linked: { type: boolean, const: true }
mc_uuid: { type: string }
auth_source:
type: string
enum: [mojang, thirdparty]
description: The source captured at mint, copied onto the durable link.
'400':
description: Invalid or expired code.
content:
+14 -7
View File
@@ -39,6 +39,9 @@ type fakeRepo struct {
// account linking (spec §10)
linkCodes map[string]fakeLinkCode // code -> pending binding
links map[string]string // mc_uuid -> user_id (mirrors UNIQUE(mc_uuid))
// linkAuthSource mirrors account_links.auth_source (mc_uuid -> mojang|thirdparty),
// the value copied from the consumed code at verify (migration 0005).
linkAuthSource map[string]string
// world backups (spec §7, §22). A nil slice lists empty.
backups []fakeBackup
// local-password auth (spec §B). staff is keyed by username (the login key);
@@ -86,6 +89,7 @@ type fakeBackup struct {
// fakeLinkCode mirrors an account_link_codes row.
type fakeLinkCode struct {
mcUUID string
authSource string
expiresAt time.Time
}
@@ -98,6 +102,7 @@ func newFakeRepo() *fakeRepo {
claimOK: map[string]bool{},
seeded: map[string]bool{}, aliases: map[string]string{},
linkCodes: map[string]fakeLinkCode{}, links: map[string]string{},
linkAuthSource: map[string]string{},
staff: map[string]*StaffUser{},
sessions: map[string]*fakeSession{},
settings: map[string][]byte{},
@@ -119,27 +124,29 @@ func (f *fakeRepo) ServerByName(_ context.Context, n string) (*ServerRecord, err
}
func (f *fakeRepo) IsLinked(_ context.Context, u string) (bool, error) { return f.linked[u], nil }
func (f *fakeRepo) QuotaAvailable(_ context.Context, u string) (bool, error) { return f.quota[u], nil }
func (f *fakeRepo) CreateLinkCode(_ context.Context, code, mcUUID string, expiresAt time.Time) error {
f.linkCodes[code] = fakeLinkCode{mcUUID: mcUUID, expiresAt: expiresAt}
func (f *fakeRepo) CreateLinkCode(_ context.Context, code, mcUUID, authSource string, expiresAt time.Time) error {
f.linkCodes[code] = fakeLinkCode{mcUUID: mcUUID, authSource: authSource, expiresAt: expiresAt}
return nil
}
// VerifyLinkCode mirrors PGRepo.VerifyLinkCode exactly so the hermetic tests
// exercise the same contract the integration impl honors: strict expiry against
// the passed clock, a different-user UUID → ErrConflict WITHOUT consuming the
// code, same (user, uuid) idempotent, and the code consumed only on success.
func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now time.Time) (string, error) {
// code, same (user, uuid) idempotent, the code's auth_source copied onto the link
// (and refreshed on re-verify), and the code consumed only on success.
func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now time.Time) (string, string, error) {
rec, ok := f.linkCodes[code]
if !ok || !rec.expiresAt.After(now) {
return "", ErrLinkCodeInvalid
return "", "", ErrLinkCodeInvalid
}
if existing, ok := f.links[rec.mcUUID]; ok && existing != userID {
return "", ErrConflict // do not consume another user's pending code
return "", "", ErrConflict // do not consume another user's pending code
}
f.links[rec.mcUUID] = userID
f.linkAuthSource[rec.mcUUID] = rec.authSource // copy/refresh, mirrors DO UPDATE
f.linked[userID] = true
delete(f.linkCodes, code)
return rec.mcUUID, nil
return rec.mcUUID, rec.authSource, nil
}
// CreateEmailOTP / VerifyEmailOTP mirror PGRepo's contract so the hermetic tests
+37 -4
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"`
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,
+55 -2
View File
@@ -57,8 +57,8 @@ func TestAccountLinkVertical(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if b := acctBody(t, w); b["linked"] != true || b["mc_uuid"] != mcUUID {
t.Fatalf("verify body = %v, want linked:true mc_uuid:%s", b, mcUUID)
if b := acctBody(t, w); b["linked"] != true || b["mc_uuid"] != mcUUID || b["auth_source"] != authSourceMojang {
t.Fatalf("verify body = %v, want linked:true mc_uuid:%s auth_source:%s", b, mcUUID, authSourceMojang)
}
// The link is audited as account.link by the principal's Access email.
if n := len(repo.audits); n != 1 || repo.audits[0].Action != "account.link" || repo.audits[0].Actor != "[email protected]" {
@@ -107,6 +107,10 @@ func TestCreateLinkCode(t *testing.T) {
if rec.mcUUID != mcUUID {
t.Errorf("stored mc_uuid = %q, want %q", rec.mcUUID, mcUUID)
}
// An omitted auth_source defaults to the Mojang-priority source.
if rec.authSource != authSourceMojang {
t.Errorf("default authSource = %q, want %q", rec.authSource, authSourceMojang)
}
if want := api.now().Add(linkCodeTTL); !rec.expiresAt.Equal(want) {
t.Errorf("expiresAt = %v, want %v", rec.expiresAt, want)
}
@@ -116,6 +120,24 @@ func TestCreateLinkCode(t *testing.T) {
}
}
})
t.Run("explicit thirdparty is stored", func(t *testing.T) {
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}`
w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil)
if w.Code != http.StatusCreated {
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
}
code, _ := acctBody(t, w)["code"].(string)
if rec := repo.linkCodes[code]; rec.authSource != authSourceThirdParty {
t.Errorf("stored authSource = %q, want %q", rec.authSource, authSourceThirdParty)
}
})
t.Run("unrecognised auth_source -> 400", func(t *testing.T) {
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"litebans"}`
w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("code = %d body %s, want 400 bad_request", w.Code, w.Body.String())
}
})
}
// TestLinkVerifyRejections is the verify failure matrix. The expired case seeds a
@@ -206,6 +228,37 @@ func TestLinkVerifyIdempotent(t *testing.T) {
}
}
// TestLinkAuthSourcePropagates proves auth_source survives the whole §10 flow: a
// thirdparty source captured in-game at mint reaches the durable link and the
// verify response — the value the web side can never originate itself.
func TestLinkAuthSourcePropagates(t *testing.T) {
const mcUUID = "55555555-5555-5555-5555-555555555555"
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
// Mint in-game with the thirdparty Yggdrasil source.
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}`
w := do(api.InternalHandler(), "POST", "/api/v1/internal/account/link/code", body, nil)
if w.Code != http.StatusCreated {
t.Fatalf("mint: code = %d, want 201 (%s)", w.Code, w.Body.String())
}
code, _ := acctBody(t, w)["code"].(string)
// Verify on the web: the response and the stored link must both carry thirdparty.
w = do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"`+code+`"}`, nil)
if w.Code != http.StatusOK {
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if got := acctBody(t, w)["auth_source"]; got != authSourceThirdParty {
t.Errorf("verify body auth_source = %v, want %q", got, authSourceThirdParty)
}
if got := repo.linkAuthSource[mcUUID]; got != authSourceThirdParty {
t.Errorf("stored link auth_source = %q, want %q", got, authSourceThirdParty)
}
}
// TestLinkStart pins the status endpoint handleClaim's 412 points at: it reports
// link state and instructions, and never mints (it has no UUID to mint against).
func TestLinkStart(t *testing.T) {
+23 -18
View File
@@ -58,10 +58,10 @@ func (p *PGRepo) IsLinked(ctx context.Context, userID string) (bool, error) {
// driven by one authoritative clock. The code is a PRIMARY KEY; a collision on
// the crypto/rand value is astronomically unlikely but surfaces as a plain
// driver error (the caller can retry) rather than being masked here.
func (p *PGRepo) CreateLinkCode(ctx context.Context, code, mcUUID string, expiresAt time.Time) error {
func (p *PGRepo) CreateLinkCode(ctx context.Context, code, mcUUID, authSource string, expiresAt time.Time) error {
_, err := p.db.ExecContext(ctx,
`INSERT INTO account_link_codes (code, mc_uuid, expires_at) VALUES ($1, $2, $3)`,
code, mcUUID, expiresAt)
`INSERT INTO account_link_codes (code, mc_uuid, auth_source, expires_at) VALUES ($1, $2, $3, $4)`,
code, mcUUID, authSource, expiresAt)
return err
}
@@ -73,21 +73,21 @@ func (p *PGRepo) CreateLinkCode(ctx context.Context, code, mcUUID string, expire
// owner's pending code. The UNIQUE(mc_uuid) constraint is the last-resort guard
// against a concurrent racer that passed the SELECT; that loses to a 500, which
// is acceptable for this integration-only path.
func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (string, error) {
func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (string, string, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return "", err
return "", "", err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
var mcUUID string
var mcUUID, authSource string
switch err := tx.QueryRowContext(ctx,
`SELECT mc_uuid FROM account_link_codes WHERE code = $1 AND expires_at > $2`,
code, now).Scan(&mcUUID); {
`SELECT mc_uuid, auth_source FROM account_link_codes WHERE code = $1 AND expires_at > $2`,
code, now).Scan(&mcUUID, &authSource); {
case errors.Is(err, sql.ErrNoRows):
return "", ErrLinkCodeInvalid
return "", "", ErrLinkCodeInvalid
case err != nil:
return "", err
return "", "", err
}
// If this UUID is already linked, only the same user may re-verify (idempotent);
@@ -98,26 +98,31 @@ func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now ti
case errors.Is(err, sql.ErrNoRows):
// not yet linked — fall through to insert
case err != nil:
return "", err
return "", "", err
default:
if existingUser != userID {
return "", ErrConflict
return "", "", ErrConflict
}
}
// Copy the code's auth_source onto the durable link. On the idempotent
// re-verify path DO UPDATE refreshes it (a player who re-linked via a different
// Yggdrasil this time gets the latest source stored), keeping the persisted
// value equal to the one returned to the caller.
if _, err := tx.ExecContext(ctx,
`INSERT INTO account_links (user_id, mc_uuid) VALUES ($1, $2)
ON CONFLICT (user_id, mc_uuid) DO NOTHING`, userID, mcUUID); err != nil {
return "", fmt.Errorf("write account link: %w", err)
`INSERT INTO account_links (user_id, mc_uuid, auth_source) VALUES ($1, $2, $3)
ON CONFLICT (user_id, mc_uuid) DO UPDATE SET auth_source = EXCLUDED.auth_source`,
userID, mcUUID, authSource); err != nil {
return "", "", fmt.Errorf("write account link: %w", err)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM account_link_codes WHERE code = $1`, code); err != nil {
return "", fmt.Errorf("consume link code: %w", err)
return "", "", fmt.Errorf("consume link code: %w", err)
}
if err := tx.Commit(); err != nil {
return "", err
return "", "", err
}
return mcUUID, nil
return mcUUID, authSource, nil
}
// QuotaAvailable treats a missing quota row or a NULL max_servers as unlimited;
+12 -9
View File
@@ -114,18 +114,21 @@ type Repo interface {
IsLinked(ctx context.Context, userID string) (bool, error)
// CreateLinkCode mints a one-time account-link code for an in-game player
// (spec §10: 游戏内 /link → 生成一次性码). The code is born knowing only the
// verified mc_uuid (online-mode=true established it); a web user binds it to
// their user_id later via VerifyLinkCode. This is internal-face only — the web
// has no verified UUID to mint against (the account_link_codes schema has no
// user_id column, which forces the in-game origin).
CreateLinkCode(ctx context.Context, code, mcUUID string, expiresAt time.Time) error
// verified mc_uuid (online-mode=true established it) and the authSource that
// established it (mojang|thirdparty, spec §10 dual-Yggdrasil); a web user binds
// it to their user_id later via VerifyLinkCode. This is internal-face only — the
// web has no verified UUID to mint against (the account_link_codes schema has no
// user_id column, which forces the in-game origin), nor does it see the
// authentication, which is why authSource also originates here.
CreateLinkCode(ctx context.Context, code, mcUUID, authSource string, expiresAt time.Time) error
// VerifyLinkCode consumes a non-expired code for the logged-in user and writes
// the account_links binding, atomically (spec §10: 网页 verify 填码 → 写
// account_links). It returns the bound mc_uuid. A missing or expired code →
// account_links). It returns the bound mc_uuid and the authSource captured at
// mint (copied from the code onto the durable link). A missing or expired code →
// ErrLinkCodeInvalid; a uuid already linked to a *different* user → ErrConflict;
// re-verifying the same (user, uuid) pair is idempotent. now is the API clock so
// expiry is testable.
VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID string, err error)
// re-verifying the same (user, uuid) pair is idempotent and refreshes the stored
// authSource. now is the API clock so expiry is testable.
VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID, authSource string, err error)
// QuotaAvailable reports whether the user is under their max_servers quota
// (spec §9.3 step ②, evaluated before provisioning).
QuotaAvailable(ctx context.Context, userID string) (bool, error)
@@ -0,0 +1,17 @@
-- Player onboarding (spec §10, dual-Yggdrasil): record HOW the in-game identity
-- authenticated when a link code was minted — 'mojang' (Mojang/official Yggdrasil,
-- the priority source) or 'thirdparty' (a configured alternate Yggdrasil). The
-- value is known only in-game at the moment online-mode auth established the UUID,
-- so it is captured on the code at mint time and copied onto the durable link at
-- verify. The web verify side never sees the authentication and cannot originate
-- it — the same constraint that puts mc_uuid (not user_id) on a code.
--
-- DEFAULT 'mojang' backfills any code/link rows that predate this column and gives
-- a Mojang-priority default for a mint that omits the field; the mint path supplies
-- it explicitly going forward.
CREATE TYPE link_auth_source AS ENUM ('mojang','thirdparty');
ALTER TABLE account_link_codes
ADD COLUMN auth_source link_auth_source NOT NULL DEFAULT 'mojang';
ALTER TABLE account_links
ADD COLUMN auth_source link_auth_source NOT NULL DEFAULT 'mojang';