Unverified Commit 1f8b9bb5 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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
parent dbe34a17
Loading
Loading
Loading
Loading
+14 −1
Changes for docs/openapi.yaml: 14 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
Changes for internal/api/api_test.go: 14 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for internal/api/handlers_account.go: 37 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for internal/api/handlers_account_test.go: 55 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for internal/api/pgrepo.go: 23 added lines, 18 removed lines.
Original line number Diff line number Diff line
@@ -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;
Loading