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

+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) {