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:
7 files changed
+179
-48
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user