feat(payments): trusted platform signal on the session
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
Record the execution platform (kind vk|telegram|direct + device subtype ios|android|web) on each session, captured at creation and carried gateway->backend as a trusted X-Platform header, so the upcoming store-compliance gate has an unforgeable execution context. - backend.sessions gains nullable platform_kind/platform_subtype columns (migration 00011, CHECK-constrained, jet regenerated); session.Platform captures them at mint, resolve returns them, middleware exposes platform(c). kind is derived from the establish endpoint, never a client field; the account-merge session mint inherits the caller's platform. - gateway derives the platform (VK subtype from the signed vk_platform via vkauth, Telegram/direct best-effort from the client) and injects X-Platform on every authenticated backend call through the request context. - ui submits a best-effort device subtype on the telegram/guest/email login requests (new FBS subtype field); VK is server-derived from the signed params. - an unattributed session is untrusted (view-only); VK/TG self-heal on the next cold-start re-mint, direct/email on re-login. Signal plumbing only, no user-visible change; X-Platform is inert until the gate consumes it.
This commit is contained in:
@@ -211,7 +211,7 @@ type ChatResp struct {
|
||||
// brand-new account's display name and language from the validated launch fields, its
|
||||
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
|
||||
// validated launch deep-link startParam, its variant preferences (first contact only).
|
||||
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam string) (SessionResp, error) {
|
||||
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam, subtype string) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
||||
map[string]string{
|
||||
@@ -221,6 +221,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
||||
"first_name": firstName,
|
||||
"browser_tz": browserTz,
|
||||
"start_param": startParam,
|
||||
"subtype": subtype,
|
||||
}, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -230,7 +231,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
||||
// name from displayName (read client-side via VKWebAppGetUserInfo, since VK omits the
|
||||
// name from the signed launch params) and its time zone from browserTz (the client's
|
||||
// detected "±HH:MM" UTC offset). All seeds apply on first contact only.
|
||||
func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz string) (SessionResp, error) {
|
||||
func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz, subtype string) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
|
||||
map[string]string{
|
||||
@@ -238,6 +239,7 @@ func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayNa
|
||||
"language_code": languageCode,
|
||||
"display_name": displayName,
|
||||
"browser_tz": browserTz,
|
||||
"subtype": subtype,
|
||||
}, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -290,10 +292,10 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces
|
||||
|
||||
// GuestAuth provisions a guest account and mints a session, seeding its time zone
|
||||
// from browserTz (the client's detected "±HH:MM" UTC offset).
|
||||
func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp, error) {
|
||||
func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype string) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "",
|
||||
map[string]string{"browser_tz": browserTz}, &out)
|
||||
map[string]string{"browser_tz": browserTz, "subtype": subtype}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -307,10 +309,10 @@ func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language st
|
||||
}
|
||||
|
||||
// EmailLogin verifies a login code and mints a session.
|
||||
func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionResp, error) {
|
||||
func (c *Client) EmailLogin(ctx context.Context, email, code, subtype string) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/login", "", "",
|
||||
map[string]string{"email": email, "code": code}, &out)
|
||||
map[string]string{"email": email, "code": code, "subtype": subtype}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -331,16 +333,24 @@ func (c *Client) EmailConfirmLink(ctx context.Context, token string) (ConfirmLin
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ResolveSession maps a token to its account id and guest flag (gateway
|
||||
// session-cache miss). The guest flag lets the edge gate guest-forbidden ops.
|
||||
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) {
|
||||
// ResolveSession maps a token to its account id, guest flag, and trusted platform
|
||||
// (gateway session-cache miss). The guest flag lets the edge gate guest-forbidden
|
||||
// ops; the platform ("<kind>/<subtype>", empty for an untrusted session) is injected
|
||||
// as X-Platform on the caller's backend requests.
|
||||
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, string, error) {
|
||||
var out struct {
|
||||
UserID string `json:"user_id"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
UserID string `json:"user_id"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
PlatformKind string `json:"platform_kind"`
|
||||
PlatformSubtype string `json:"platform_subtype"`
|
||||
}
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
|
||||
map[string]string{"token": token}, &out)
|
||||
return out.UserID, out.IsGuest, err
|
||||
platform := ""
|
||||
if out.PlatformKind != "" {
|
||||
platform = out.PlatformKind + "/" + out.PlatformSubtype
|
||||
}
|
||||
return out.UserID, out.IsGuest, platform, err
|
||||
}
|
||||
|
||||
// Profile returns the authenticated account's profile.
|
||||
|
||||
@@ -118,8 +118,30 @@ func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("backend %d (%s): %s", e.Status, e.Code, e.Message)
|
||||
}
|
||||
|
||||
// platformCtxKey types the request-context slot the trusted X-Platform value rides in.
|
||||
type platformCtxKey struct{}
|
||||
|
||||
// WithPlatform returns a copy of ctx carrying the trusted platform header value
|
||||
// ("<kind>/<subtype>", e.g. "vk/ios") that do and getRaw inject as X-Platform on the
|
||||
// outbound backend request. An empty platform (an untrusted session) leaves ctx
|
||||
// unchanged, so no header is sent and the backend treats the request as untrusted.
|
||||
func WithPlatform(ctx context.Context, platform string) context.Context {
|
||||
if platform == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, platformCtxKey{}, platform)
|
||||
}
|
||||
|
||||
// platformFromContext returns the platform header value stored by WithPlatform, or
|
||||
// an empty string when none is present.
|
||||
func platformFromContext(ctx context.Context) string {
|
||||
p, _ := ctx.Value(platformCtxKey{}).(string)
|
||||
return p
|
||||
}
|
||||
|
||||
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
||||
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation). A non-2xx
|
||||
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
|
||||
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
|
||||
// response is returned as an *APIError carrying the backend error code.
|
||||
func (c *Client) do(ctx context.Context, method, path, userID, clientIP string, body, out any) error {
|
||||
var reader io.Reader
|
||||
@@ -141,6 +163,9 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
|
||||
if clientIP != "" {
|
||||
req.Header.Set("X-Forwarded-For", clientIP)
|
||||
}
|
||||
if p := platformFromContext(ctx); p != "" {
|
||||
req.Header.Set("X-Platform", p)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backendclient: %s %s: %w", method, path, err)
|
||||
@@ -174,6 +199,9 @@ func (c *Client) getRaw(ctx context.Context, path, userID, respHeader string) ([
|
||||
if userID != "" {
|
||||
req.Header.Set("X-User-ID", userID)
|
||||
}
|
||||
if p := platformFromContext(ctx); p != "" {
|
||||
req.Header.Set("X-Platform", p)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("backendclient: GET %s: %w", path, err)
|
||||
|
||||
@@ -82,3 +82,43 @@ func TestSyncBans(t *testing.T) {
|
||||
t.Fatalf("unban = %v, want [203.0.113.9]", unban)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXPlatformInjection verifies the trusted platform carried on the context
|
||||
// (WithPlatform) rides an authenticated backend request as X-Platform, and that an
|
||||
// untrusted context (no platform) sends no header at all — the fail-closed default.
|
||||
func TestXPlatformInjection(t *testing.T) {
|
||||
var gotPlatform string
|
||||
var hadHeader bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPlatform = r.Header.Get("X-Platform")
|
||||
_, hadHeader = r.Header["X-Platform"]
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("backendclient: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
t.Run("trusted forwards header", func(t *testing.T) {
|
||||
ctx := backendclient.WithPlatform(context.Background(), "vk/ios")
|
||||
if _, err := c.Profile(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Profile: %v", err)
|
||||
}
|
||||
if gotPlatform != "vk/ios" {
|
||||
t.Fatalf("X-Platform = %q, want vk/ios", gotPlatform)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("untrusted omits header", func(t *testing.T) {
|
||||
hadHeader = true // ensure the handler actually clears it
|
||||
if _, err := c.Profile(context.Background(), "user-1"); err != nil {
|
||||
t.Fatalf("Profile: %v", err)
|
||||
}
|
||||
if hadHeader {
|
||||
t.Fatal("X-Platform must be absent for an untrusted context")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user