From fe2ece08cc41373d6ba94ad5ea46202529859e1f Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Wed, 1 Jul 2026 18:00:13 +0900 Subject: [PATCH] feat(api): add public Bind-Code onboarding for the player console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /api/v1/auth/bind, the one public pre-account entrypoint of the player console (console.). An account-less player redeems the one-time Bind Code minted in the in-game Login Lobby; in a single step the platform creates a role=user player, links it to the verified in-game UUID, and mints a host-only felis_session. Login is thus not forced at the edge while operations stay app-authenticated. The operator console (op.console.) is unaffected and stays behind Zero Trust: a code whose UUID resolves to a staff (role=admin) account is refused with 403 (ErrPlayerBindForbidden) without consuming the code, so the public door provably never yields an admin principal — the session it mints carries ViaAdminAccess=false and is host-only to console, never sent to op.console. Repo layer: new RedeemPlayerBindCode on the Repo interface, implemented on PGRepo (single tx: resolve code, create-or-fetch the player, consume) and the test fake. The returning-player branch is idempotent and is a deliberate standing "log in via the game" door, not just first-time onboarding. Honest labeling: - ORACLE-VERIFIED (Go): account/session logic — role=user, refuse-staff, idempotent create-or-fetch, single-use code, and the op.console redline (player session rejected on admin routes). Covered by handlers_onboard_test and the OpenAPI parity gate. - INTEGRATION-dependent: the endpoint's security rests on the Bind Code having been minted against an online-mode-Yggdrasil-authenticated UUID, a precondition that lives in velocity/Java and is not verifiable from this repo (CODE-ONLY). The Go layer proves the logic, not that identity guarantee. - No app-level attempt cap: rate-limiting is deferred to the edge as for the public /auth/login; the ~1e12 keyspace, single use and short TTL make a blind app-level cap non-critical. --- docs/openapi.yaml | 56 ++++++ internal/api/api.go | 8 + internal/api/api_test.go | 35 ++++ internal/api/errors.go | 7 + internal/api/handlers_onboard.go | 131 +++++++++++++ internal/api/handlers_onboard_test.go | 261 ++++++++++++++++++++++++++ internal/api/pgrepo.go | 64 +++++++ internal/api/repo.go | 23 +++ 8 files changed, 585 insertions(+) create mode 100644 internal/api/handlers_onboard.go create mode 100644 internal/api/handlers_onboard_test.go diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 02a038a..310dd19 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1338,6 +1338,62 @@ paths: '403': $ref: '#/components/responses/Forbidden' + /api/v1/auth/bind: + post: + tags: [auth] + operationId: bindRedeem + summary: Redeem a Bind Code into a player account + session (public console bootstrap, spec §10/§B). + description: >- + The one public, pre-account entrypoint of the player console + (console.): an account-less player redeems the one-time Bind + Code they generated in the in-game Login Lobby, and the platform creates their + player account (role=user), binds it to the verified in-game UUID, and mints a + host-only session cookie. Safe to expose unauthenticated because the code is + minted internal-face only, against an online-mode-verified UUID, with a short + TTL and single use — possession already proves control of a Minecraft identity. + An already-linked player UUID logs that player back in (idempotent); a UUID + that belongs to staff is refused (403) — operators authenticate at op.console + behind Zero Trust, so this never mints a session for an admin identity. Requires + local sessions to be enabled (same toggle as login). + x-felis-face: [external] + x-felis-tier: public + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: { type: string } + responses: + '200': + description: Player account bootstrapped; the session cookie is set on the response. + content: + application/json: + schema: + type: object + required: [user_id, linked, mc_uuid, auth_source] + properties: + user_id: { type: string } + 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 bind code. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '403': + description: Local sessions are disabled, or the code's UUID belongs to a staff account. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + /api/v1/me: get: tags: [servers] diff --git a/internal/api/api.go b/internal/api/api.go index b0a4cb3..6bdbac4 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -227,6 +227,14 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "POST", Pattern: "/api/v1/auth/login", Public: true, h: a.handleLogin}, {Method: "POST", Pattern: "/api/v1/auth/logout", Public: true, h: a.handleLogout}, {Method: "POST", Pattern: "/api/v1/auth/change-password", AllowDuringPasswordChange: true, h: a.handleChangePassword}, + // Player-console bootstrap (console-tier access model): the account-less + // player's door into console.. Public — like login there is no prior + // principal — and session-minting, but the artifact it consumes is a one-time + // Bind Code minted internal-face against an online-mode-verified UUID, so + // possession already proves a Minecraft identity. A code whose UUID belongs to + // staff is refused (403) so this never yields an admin session; op.console stays + // behind Zero Trust (handlers_onboard.go). + {Method: "POST", Pattern: "/api/v1/auth/bind", Public: true, h: a.handleBindRedeem}, // App-auth tier: operations on your own servers (spec §14). {Method: "POST", Pattern: "/api/v1/servers/{name}/wake", h: a.handleWake}, diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 8449c8b..ddba2b3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -199,6 +199,41 @@ func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now ti return rec.mcUUID, rec.authSource, nil } +// RedeemPlayerBindCode mirrors PGRepo.RedeemPlayerBindCode: it create-or-fetches a +// player keyed on the code's verified mc_uuid. The staff map stands in for the single +// users table, so a newly created role='user' player is stored there (uuid-derived +// username) and resolves through SessionUser/UserByID just like the PG JOIN. An +// already-linked admin UUID is refused without consuming the code; an already-linked +// player is fetched idempotently. +// +// Contract gap vs PG (benign): on an orphan link (mc_uuid linked but its users row +// gone) the fake resolves no role and falls through to the idempotent return, minting +// a session for a ghost id, whereas PG's account_links⋈users JOIN would find no row, +// take the insert branch and 500 on the UNIQUE(mc_uuid) clash. The account_links.user_id +// FK makes an orphan link unreachable in production, so this divergence is untestable +// rather than a real behavioral difference. +func (f *fakeRepo) RedeemPlayerBindCode(_ context.Context, newUserID, code string, now time.Time) (string, string, string, error) { + rec, ok := f.linkCodes[code] + if !ok || !rec.expiresAt.After(now) { + return "", "", "", ErrLinkCodeInvalid + } + if existing, ok := f.links[rec.mcUUID]; ok { + for _, u := range f.staff { // resolve the linked identity to check its role + if u.ID == existing && u.Role != "user" { + return "", "", "", ErrPlayerBindForbidden // staff must use op.console; do not consume + } + } + delete(f.linkCodes, code) + return existing, rec.mcUUID, rec.authSource, nil + } + f.staff[rec.mcUUID] = &StaffUser{ID: newUserID, Username: rec.mcUUID, Role: "user"} + f.links[rec.mcUUID] = newUserID + f.linkAuthSource[rec.mcUUID] = rec.authSource + f.linked[newUserID] = true + delete(f.linkCodes, code) + return newUserID, rec.mcUUID, rec.authSource, nil +} + // CreateEmailOTP / VerifyEmailOTP mirror PGRepo's contract so the hermetic tests // exercise the same semantics the integration impl honors: a fresh code supersedes // the prior live one for (user, purpose), expiry and the attempt cap are checked diff --git a/internal/api/errors.go b/internal/api/errors.go index b8b9994..647513c 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -42,6 +42,13 @@ var ( // finish endpoint exists; the ceremony state is gone (never begun, already // consumed, or expired) — so handlers map it to 400, not 404. ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid or expired") + // ErrPlayerBindForbidden means a public Bind-Code redemption resolved to a STAFF + // account (role=admin), which the player-console bootstrap refuses (console-tier + // access model). Operators authenticate at op.console behind Zero Trust, never via + // the account-less console. door, so the public bootstrap provably + // never mints a session for an admin identity. It is distinct from ErrConflict so + // the handler answers 403 (wrong door) rather than 409 (already linked). + ErrPlayerBindForbidden = errors.New("bind code belongs to a staff account") ) // apiError is a handler-level error carrying an HTTP status and a stable, diff --git a/internal/api/handlers_onboard.go b/internal/api/handlers_onboard.go new file mode 100644 index 0000000..dffe134 --- /dev/null +++ b/internal/api/handlers_onboard.go @@ -0,0 +1,131 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "strings" +) + +// Player-console onboarding bootstrap (console-tier access model; spec §10, §B). This +// is the ONE public, pre-account entrypoint of the player console (console.): +// an account-less player redeems the one-time Bind Code they generated in the in-game +// Login Lobby, and in a single step the platform creates their player account +// (role=user), binds it to their verified in-game UUID, and mints a player session. +// From there the ordinary app-tier onboarding endpoints (email-OTP, passkey) work off +// the resulting principal like any other — the session model is role-agnostic, so a +// player session is just an opaque felis_session over a role=user row. +// +// Why this may be Public while /account/link/verify may not: a Bind Code is minted +// INTERNAL-face only (handleCreateLinkCode), with a short TTL, single use, and a 32^8 +// keyspace, so the web can never originate one (account_link_codes has no user_id) — +// no code, no account. This endpoint MATERIALLY elevates the code's authority: where +// /account/link/verify bound a UUID to an already-authenticated principal, this makes +// the code alone create an account and mint a session. That is only safe if the code +// was minted against a UUID an online-mode Yggdrasil actually authenticated — a +// precondition that lives in velocity/Java (CODE-ONLY, not verifiable from this repo). +// So the safety here is INTEGRATION-dependent on that upstream online-mode guarantee; +// the Go layer proves only the account/session logic (role, refuse-staff, idempotent), +// never the identity guarantee itself. +// +// No app-level attempt cap is enforced here (unlike the email-OTP flow, whose 1e6 +// keyspace demanded one): the code's ~1e12 keyspace, single use and short TTL make +// blind brute force non-viable, and rate-limiting is deferred to the edge exactly as +// for the public /auth/login. The idempotent returning-player branch (a UUID already +// linked to a role=user player is fetched, not re-created) is a DELIBERATE standing +// "log in via the game" door, not merely first-time onboarding: control of the +// in-game identity is the root of trust, so re-minting a code always re-grants a +// session even after email/passkey are bound. "登录并非强制,但没登录什么都干不了". +// +// op.console stays behind Zero Trust. A code whose UUID belongs to STAFF (role=admin) +// is refused here (ErrPlayerBindForbidden → 403), so the public bootstrap provably +// never mints a session for an admin identity — the sole tier it yields is a role=user +// player session, host-only to console. (never sent to op.console) and +// carrying ViaAdminAccess=false. "op.console 必须得 Auth". + +// newUserID returns an opaque random user id (128 bits, hex), matching the shape of +// the ids break-glass mints for staff rows. +func newUserID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +// bindRedeemRequest is the console bootstrap body: the Bind Code the player was shown +// in the Login Lobby. +type bindRedeemRequest struct { + Code string `json:"code"` +} + +// handleBindRedeem redeems a Bind Code into a player account + session (Public). It is +// the account-less player's only door into console.: no prior principal, +// no Zero Trust in front (unlike op.console). Like handleLogin it is a cookie-minting +// public route, so it requires local sessions to be enabled and a JSON content type +// (the cross-site-forgery guard) and mints the same host-only felis_session cookie. +// The code is trimmed and uppercased so a player who typed it with stray spaces or in +// lowercase still matches, mirroring handleLinkVerify. +func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) { + // The minted session is a felis_session cookie, honored only when local sessions + // are enabled (SessionAuth). Minting one while they are off would hand back a dead + // cookie, so refuse loudly and consistently with handleLogin. This couples the + // player bootstrap to the same toggle that gates op.console local login; a future + // deployment wanting player cookies without local admin login would decouple them + // in SessionAuth — out of scope here (KNOWN coupling). + if !localAuthEnabled(r.Context(), a.Repo) { + writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled", + "session login is disabled")) + return + } + if err := requireJSONContentType(r); err != nil { + writeError(w, r, err) + return + } + var body bindRedeemRequest + if err := decodeJSON(w, r, &body); err != nil { + writeError(w, r, err) + return + } + code := strings.ToUpper(strings.TrimSpace(body.Code)) + if code == "" { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required")) + return + } + + uid, err := newUserID() + if err != nil { + writeError(w, r, err) + return + } + userID, mcUUID, authSource, err := a.Repo.RedeemPlayerBindCode(r.Context(), uid, code, a.now()) + switch { + case errors.Is(err, ErrLinkCodeInvalid): + writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "bind code is invalid or expired")) + return + case errors.Is(err, ErrPlayerBindForbidden): + writeError(w, r, newError(http.StatusForbidden, "staff_account", + "that Minecraft account belongs to staff; sign in at the operator console")) + return + case err != nil: + writeError(w, r, err) + return + } + + token, err := newSessionToken() + if err != nil { + writeError(w, r, err) + return + } + expires := a.now().Add(sessionTTL) + if err := a.Repo.CreateSession(r.Context(), hashCookie(token), userID, expires); err != nil { + writeError(w, r, err) + return + } + setSessionCookie(w, token, expires) + a.audit(r, userID, "account.bind_redeem", "") + writeJSON(w, http.StatusOK, map[string]any{ + "user_id": userID, "linked": true, "mc_uuid": mcUUID, "auth_source": authSource, + }) +} diff --git a/internal/api/handlers_onboard_test.go b/internal/api/handlers_onboard_test.go new file mode 100644 index 0000000..be6fb8a --- /dev/null +++ b/internal/api/handlers_onboard_test.go @@ -0,0 +1,261 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// Player-console onboarding bootstrap tests (console-tier access model). The load- +// bearing cases: a Bind Code alone bootstraps a role=user player + session on the +// public console door (no prior principal, no Zero Trust); a code whose UUID belongs +// to staff is refused so the public door never mints an admin session; and — the +// invariant that keeps "op.console 必须得 Auth" true after making console. public — +// a redeemed player session is rejected on every Admin route and carries +// ViaAdminAccess=false. + +const bindTestUUID = "11111111-1111-1111-1111-111111111111" + +// seedBindAPI returns an API with local sessions enabled and its external face wired +// to the real SessionAuth, so a cookie minted by /auth/bind is actually honored on +// the follow-up authenticated requests (the whole point of the bootstrap). +func seedBindAPI(t *testing.T) (*API, *fakeRepo) { + t.Helper() + repo := newFakeRepo() + repo.settings[LocalAuthEnabledKey] = []byte("true") + api := newTestAPI(repo, newFakeCluster()) + api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now} + return api, repo +} + +// mintBindCode stores a live Bind Code for uuid/authSource, mirroring the internal +// mint (handleCreateLinkCode → CreateLinkCode). +func mintBindCode(t *testing.T, api *API, repo *fakeRepo, code, uuid, authSource string) { + t.Helper() + if err := repo.CreateLinkCode(t.Context(), code, uuid, authSource, api.now().Add(linkCodeTTL)); err != nil { + t.Fatalf("mint bind code: %v", err) + } +} + +// sessionCookieValue returns the raw felis_session cookie value set on w, or fails. +func sessionCookieValue(t *testing.T, w *httptest.ResponseRecorder) string { + t.Helper() + for _, c := range w.Result().Cookies() { + if c.Name == sessionCookieName && c.Value != "" { + return c.Value + } + } + t.Fatalf("no %s cookie set (%s)", sessionCookieName, w.Body.String()) + return "" +} + +// TestBindRedeemBootstrapsPlayer is the happy path: an account-less player redeems a +// Bind Code and, in one step, gets a role=user account, an account_links binding, and +// a live session — which then resolves through the external face on /me. +func TestBindRedeemBootstrapsPlayer(t *testing.T) { + api, repo := seedBindAPI(t) + mintBindCode(t, api, repo, "ABCD2345", bindTestUUID, authSourceMojang) + + w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"ABCD2345"}`, jsonHeader) + if w.Code != http.StatusOK { + t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + + var got map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("body not JSON: %v (%s)", err, w.Body.String()) + } + userID, _ := got["user_id"].(string) + if userID == "" || got["linked"] != true || got["mc_uuid"] != bindTestUUID || got["auth_source"] != authSourceMojang { + t.Fatalf("body = %v, want a user_id, linked=true, mc_uuid+auth_source echoed", got) + } + + // A fresh role=user player row was created and bound; the code was consumed. + if u := repo.staff[bindTestUUID]; u == nil || u.Role != "user" || u.PasswordHash != "" || u.ID != userID { + t.Fatalf("created row = %+v, want role=user, NULL hash, id=%s", u, userID) + } + if repo.links[bindTestUUID] != userID { + t.Fatalf("account_links[%s] = %q, want %q", bindTestUUID, repo.links[bindTestUUID], userID) + } + if _, live := repo.linkCodes["ABCD2345"]; live { + t.Fatal("the bind code must be consumed on success") + } + + // The minted session must resolve through the external face: /me returns the + // player identity, role=user, is_admin=false. + token := sessionCookieValue(t, w) + me := do(api.ExternalHandler(), "GET", "/api/v1/me", "", + map[string]string{"Cookie": sessionCookieName + "=" + token}) + if me.Code != http.StatusOK { + t.Fatalf("/me code = %d, want 200 (%s)", me.Code, me.Body.String()) + } + var meBody map[string]any + if err := json.Unmarshal(me.Body.Bytes(), &meBody); err != nil { + t.Fatalf("/me body not JSON: %v", err) + } + if meBody["role"] != "user" || meBody["is_admin"] != false || meBody["user_id"] != userID { + t.Fatalf("/me = %v, want role=user is_admin=false user_id=%s", meBody, userID) + } +} + +// TestBindRedeemIdempotentReturningPlayer proves a second redemption of the SAME UUID +// logs the same player back in (idempotent "log in via the game") rather than forking +// a duplicate account. +func TestBindRedeemIdempotentReturningPlayer(t *testing.T) { + api, repo := seedBindAPI(t) + + mintBindCode(t, api, repo, "FIRST234", bindTestUUID, authSourceMojang) + w1 := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"FIRST234"}`, jsonHeader) + if w1.Code != http.StatusOK { + t.Fatalf("first redeem code = %d, want 200 (%s)", w1.Code, w1.Body.String()) + } + var b1 map[string]any + _ = json.Unmarshal(w1.Body.Bytes(), &b1) + + mintBindCode(t, api, repo, "SECOND34", bindTestUUID, authSourceThirdParty) + w2 := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"SECOND34"}`, jsonHeader) + if w2.Code != http.StatusOK { + t.Fatalf("second redeem code = %d, want 200 (%s)", w2.Code, w2.Body.String()) + } + var b2 map[string]any + _ = json.Unmarshal(w2.Body.Bytes(), &b2) + + if b1["user_id"] != b2["user_id"] { + t.Fatalf("returning player got a new account: %v then %v", b1["user_id"], b2["user_id"]) + } +} + +// TestBindRedeemRefusesStaffAccount is the op.console red line at the data layer: a +// Bind Code whose UUID belongs to a STAFF account (role=admin) is refused 403, and the +// code is NOT consumed and no session is minted. The public console door therefore can +// never yield a session for an admin identity. +func TestBindRedeemRefusesStaffAccount(t *testing.T) { + api, repo := seedBindAPI(t) + // An admin already linked to this UUID (e.g. an Operator on the thirdparty Yggdrasil). + repo.staff["op"] = &StaffUser{ID: "admin1", Username: "op", Role: "admin"} + repo.links[bindTestUUID] = "admin1" + mintBindCode(t, api, repo, "STAFF234", bindTestUUID, authSourceThirdParty) + + w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"STAFF234"}`, jsonHeader) + if w.Code != http.StatusForbidden { + t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String()) + } + if code := decodeErr(t, w); code != "staff_account" { + t.Fatalf("error code = %q, want staff_account", code) + } + if len(w.Result().Cookies()) != 0 { + t.Fatal("no session cookie may be set when a staff UUID is refused") + } + if _, live := repo.linkCodes["STAFF234"]; !live { + t.Fatal("a refused staff redemption must NOT consume the code") + } +} + +// TestPlayerSessionRejectedOnAdminRoutes is the invariant that lets console. be +// public without weakening op.console: a session minted by the public bootstrap is a +// role=user session, so every Admin route rejects it (403) and it carries +// ViaAdminAccess=false even on the operator host. +func TestPlayerSessionRejectedOnAdminRoutes(t *testing.T) { + api, repo := seedBindAPI(t) + mintBindCode(t, api, repo, "PLAYER34", bindTestUUID, authSourceMojang) + w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"PLAYER34"}`, jsonHeader) + if w.Code != http.StatusOK { + t.Fatalf("redeem code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + token := sessionCookieValue(t, w) + + // An Admin route (POST /api/v1/servers), requested on the OPERATOR host with the + // player cookie, is rejected before the handler: the player is role=user, so + // IsAdmin() is false regardless of host. + adm := do(api.ExternalHandler(), "POST", "https://op.console.mc.example.net/api/v1/servers", `{}`, + map[string]string{"Cookie": sessionCookieName + "=" + token}) + if adm.Code != http.StatusForbidden { + t.Fatalf("player on Admin route: code = %d, want 403 (%s)", adm.Code, adm.Body.String()) + } + + // Directly: SessionAuth resolves the player on the operator host WITHOUT admin + // access. A role=user can never satisfy ViaAdminAccess (it is admin-AND-host), so + // the public bootstrap provably yields no admin principal. + auth := SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now} + r := httptest.NewRequest("GET", "https://op.console.mc.example.net/api/v1/me", nil) + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token}) + p, err := auth.Authenticate(r) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if p.Role != "user" || p.ViaAdminAccess || p.IsAdmin() { + t.Fatalf("player principal = %+v, want role=user ViaAdminAccess=false IsAdmin=false", p) + } +} + +func TestBindRedeemInvalidCode(t *testing.T) { + api, _ := seedBindAPI(t) + for _, tc := range []struct{ name, body string }{ + {"unknown code", `{"code":"NOPE2345"}`}, + {"empty code", `{"code":""}`}, + } { + t.Run(tc.name, func(t *testing.T) { + w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", tc.body, jsonHeader) + if w.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String()) + } + if len(w.Result().Cookies()) != 0 { + t.Fatal("no session cookie may be set on an invalid redeem") + } + }) + } +} + +// TestBindRedeemExpiredCode proves expiry is enforced against the API clock: a code +// past its TTL is invalid_code, never a silent bootstrap. +func TestBindRedeemExpiredCode(t *testing.T) { + api, repo := seedBindAPI(t) + repo.linkCodes["OLD23456"] = fakeLinkCode{ + mcUUID: bindTestUUID, authSource: authSourceMojang, + expiresAt: api.now().Add(-time.Minute), // already expired + } + w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"OLD23456"}`, jsonHeader) + if w.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String()) + } + if code := decodeErr(t, w); code != "invalid_code" { + t.Fatalf("error code = %q, want invalid_code", code) + } +} + +// TestBindRedeemLocalAuthDisabled proves the bootstrap refuses to mint a session that +// SessionAuth would not honor: with local sessions off it returns 403, never a dead +// cookie, mirroring handleLogin. +func TestBindRedeemLocalAuthDisabled(t *testing.T) { + repo := newFakeRepo() // local_auth_enabled never set → fail closed + api := newTestAPI(repo, newFakeCluster()) + mintBindCode(t, api, repo, "DEAD2345", bindTestUUID, authSourceMojang) + w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"DEAD2345"}`, jsonHeader) + if w.Code != http.StatusForbidden { + t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String()) + } + if code := decodeErr(t, w); code != "local_auth_disabled" { + t.Fatalf("error code = %q, want local_auth_disabled", code) + } + if len(w.Result().Cookies()) != 0 { + t.Fatal("no session cookie may be minted while local auth is disabled") + } +} + +// TestBindRedeemContentTypeGuard pins the cross-site-forgery guard: a body whose +// Content-Type an HTML form could emit is rejected 415 before any redemption, so a +// forged off-origin POST cannot bootstrap an account. +func TestBindRedeemContentTypeGuard(t *testing.T) { + api, repo := seedBindAPI(t) + mintBindCode(t, api, repo, "FORM2345", bindTestUUID, authSourceMojang) + w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"FORM2345"}`, + map[string]string{"Content-Type": "application/x-www-form-urlencoded"}) + if w.Code != http.StatusUnsupportedMediaType { + t.Fatalf("code = %d, want 415 (%s)", w.Code, w.Body.String()) + } + if _, live := repo.linkCodes["FORM2345"]; !live { + t.Fatal("a content-type-rejected redeem must not consume the code") + } +} diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 84167d8..ad74d3d 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -125,6 +125,70 @@ func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now ti return mcUUID, authSource, nil } +// RedeemPlayerBindCode redeems a Bind Code into a player account + link in one +// transaction (console-tier access model). It mirrors VerifyLinkCode's structure — +// strict expiry against the passed clock, the durable-link write, and the DELETE +// that consumes the code — but creates or fetches the user instead of requiring one. +// See the Repo interface for the full contract. Like VerifyLinkCode a concurrent +// racer that passed the SELECT loses to the UNIQUE(mc_uuid)/UNIQUE(username) guard +// (a 500), acceptable for this integration-only path; the primary idempotency is the +// mc_uuid-keyed fetch below. +func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code string, now time.Time) (string, string, string, error) { + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return "", "", "", err + } + defer tx.Rollback() //nolint:errcheck // no-op after commit + + var mcUUID, authSource string + switch err := tx.QueryRowContext(ctx, + `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 + case err != nil: + return "", "", "", err + } + + // Create-or-fetch keyed on the verified UUID. An already-linked role='user' player + // is fetched (idempotent "log in via the game"); a role='admin' STAFF account is + // refused (op.console only) BEFORE any consume, so the code survives; an unlinked + // UUID births a fresh role='user' player with a uuid-derived unique username. + userID := newUserID + var existingRole string + switch err := tx.QueryRowContext(ctx, + `SELECT u.id, u.role::text FROM account_links al JOIN users u ON u.id = al.user_id WHERE al.mc_uuid = $1`, + mcUUID).Scan(&userID, &existingRole); { + case errors.Is(err, sql.ErrNoRows): + if _, err := tx.ExecContext(ctx, + `INSERT INTO users (id, username, role) VALUES ($1, $2, 'user')`, + newUserID, mcUUID); err != nil { + return "", "", "", fmt.Errorf("create player: %w", err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO account_links (user_id, mc_uuid, auth_source) VALUES ($1, $2, $3)`, + newUserID, mcUUID, authSource); err != nil { + return "", "", "", fmt.Errorf("write account link: %w", err) + } + userID = newUserID + case err != nil: + return "", "", "", err + default: + if existingRole != "user" { + return "", "", "", ErrPlayerBindForbidden // staff must use op.console + } + } + + if _, err := tx.ExecContext(ctx, + `DELETE FROM account_link_codes WHERE code = $1`, code); err != nil { + return "", "", "", fmt.Errorf("consume link code: %w", err) + } + if err := tx.Commit(); err != nil { + return "", "", "", err + } + return userID, mcUUID, authSource, nil +} + // QuotaAvailable treats a missing quota row or a NULL max_servers as unlimited; // otherwise it compares the live owned-server count against the cap (spec §9.3). func (p *PGRepo) QuotaAvailable(ctx context.Context, userID string) (bool, error) { diff --git a/internal/api/repo.go b/internal/api/repo.go index 96220f6..364a359 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -149,6 +149,29 @@ type Repo interface { // 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) + // RedeemPlayerBindCode is the account-less player-console bootstrap (console-tier + // access model): it redeems a one-time Bind Code into a PLAYER account + link in + // one atomic step, so a first-time player with no Felis account can create one + // from the console. door. Unlike VerifyLinkCode it takes NO prior + // user — it creates or fetches one, keyed on the verified mc_uuid the code carries: + // + // - code missing/expired → ErrLinkCodeInvalid (does not consume it); + // - the uuid is not yet linked → create a role='user' player row with id + // newUserID (NULL password_hash, username derived from the uuid so it is unique + // and deterministic), write the account_links binding, consume the code, and + // return newUserID; + // - the uuid is already linked to a role='user' player → return THAT user + // (idempotent "log in via the game"), consuming the code; + // - the uuid is linked to a role='admin' STAFF account → ErrPlayerBindForbidden + // WITHOUT consuming the code (operators use op.console behind Zero Trust; the + // public bootstrap never mints a session for an admin identity). + // + // Safe as an unauthenticated entrypoint because a Bind Code is minted internal-face + // only (CreateLinkCode), against an online-mode-verified UUID, short-TTL and + // single-use — possession already proves control of a Minecraft identity. now is + // the API clock so expiry is testable. It returns the effective userID plus the + // bound mc_uuid and authSource (for the response + audit). + RedeemPlayerBindCode(ctx context.Context, newUserID, code string, now time.Time) (userID, 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)