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

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:
Ilia Denisov
2026-07-08 03:31:51 +02:00
parent 07815c5a30
commit 92633f935e
39 changed files with 970 additions and 221 deletions
+22 -12
View File
@@ -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.
+29 -1
View File
@@ -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")
}
})
}
+14 -10
View File
@@ -275,7 +275,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
if op.Auth {
uid, isGuest, err := s.resolve(ctx, req.Header(), clientIP)
uid, isGuest, platform, err := s.resolve(ctx, req.Header(), clientIP)
if err != nil {
result = "unauthenticated"
return nil, err
@@ -297,6 +297,10 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
return nil, s.rejectRateLimited(ctx, classUser, uid, msgType)
}
tr.UserID = uid
// Carry the resolved trusted platform on the context so the backend client injects
// X-Platform on every downstream REST call for this request. Empty for an untrusted
// session ⇒ no header ⇒ the backend treats the request as untrusted (view-only).
ctx = backendclient.WithPlatform(ctx, platform)
} else {
if !s.limiter.Allow("ip:"+clientIP, s.publicPolicy) {
result = "rate_limited"
@@ -331,7 +335,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
// Subscribe streams the authenticated user's live events with a keep-alive
// heartbeat until the client disconnects.
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
uid, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
if err != nil {
return err
}
@@ -494,7 +498,7 @@ func (s *Server) dictBytesHandler() http.Handler {
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
uid, _, err := s.resolve(r.Context(), r.Header, ip)
uid, _, _, err := s.resolve(r.Context(), r.Header, ip)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -552,7 +556,7 @@ func (s *Server) localEvalMetricsHandler() http.Handler {
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if _, _, err := s.resolve(r.Context(), r.Header, ip); err != nil {
if _, _, _, err := s.resolve(r.Context(), r.Header, ip); err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
@@ -659,10 +663,10 @@ func truncate(s string, n int) string {
// resolve extracts and resolves the Authorization bearer token to an account id
// and its guest flag, returning a Connect Unauthenticated error when it is missing
// or unknown.
func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (string, bool, error) {
func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (string, bool, string, error) {
token := bearerToken(h.Get("Authorization"))
if token == "" {
return "", false, connect.NewError(connect.CodeUnauthenticated, errMissingToken)
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errMissingToken)
}
// The honeytoken is a planted value no real client holds: presenting it is a
// high-confidence intrusion signal, so ban the caller and raise the alarm, then
@@ -672,9 +676,9 @@ func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (s
if s.banlist.BanNow(clientIP, ratelimit.ReasonHoneytoken) {
s.metrics.recordBan(ctx, string(ratelimit.ReasonHoneytoken))
}
return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
}
uid, isGuest, err := s.sessions.Resolve(ctx, token)
uid, isGuest, platform, err := s.sessions.Resolve(ctx, token)
if err != nil {
// An unknown or expired token (a backend 4xx) is the client's problem and
// stays silent; anything else — a resolve timeout, a refused connection, a
@@ -685,9 +689,9 @@ func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (s
if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError {
s.log.Warn("session resolve failed", zap.Error(err))
}
return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
}
return uid, isGuest, nil
return uid, isGuest, platform, nil
}
// bearerToken extracts the token from an "Authorization: Bearer <token>" header,
+26 -24
View File
@@ -11,10 +11,11 @@ import (
"time"
)
// Resolver resolves a token to an account id and its guest flag at the backend
// (the cache miss path). backendclient.Client satisfies it.
// Resolver resolves a token to an account id, its guest flag and its trusted
// platform ("<kind>/<subtype>", empty when untrusted) at the backend (the cache
// miss path). backendclient.Client satisfies it.
type Resolver interface {
ResolveSession(ctx context.Context, token string) (string, bool, error)
ResolveSession(ctx context.Context, token string) (string, bool, string, error)
}
// Cache resolves session tokens to account ids, caching hits for ttl.
@@ -29,9 +30,10 @@ type Cache struct {
}
type entry struct {
userID string
isGuest bool
expires time.Time
userID string
isGuest bool
platform string
expires time.Time
}
// NewCache constructs a Cache over backend with the given TTL and maximum size.
@@ -48,19 +50,19 @@ func NewCache(backend Resolver, ttl time.Duration, max int) *Cache {
}
}
// Resolve returns the account id and guest flag for token, consulting the cache
// first and the backend on a miss (caching the result). An empty token is rejected
// by the backend like any unknown token.
func (c *Cache) Resolve(ctx context.Context, token string) (string, bool, error) {
if uid, guest, ok := c.lookup(token); ok {
return uid, guest, nil
// Resolve returns the account id, guest flag and trusted platform for token,
// consulting the cache first and the backend on a miss (caching the result). An
// empty token is rejected by the backend like any unknown token.
func (c *Cache) Resolve(ctx context.Context, token string) (string, bool, string, error) {
if uid, guest, platform, ok := c.lookup(token); ok {
return uid, guest, platform, nil
}
uid, guest, err := c.backend.ResolveSession(ctx, token)
uid, guest, platform, err := c.backend.ResolveSession(ctx, token)
if err != nil {
return "", false, err
return "", false, "", err
}
c.store(token, uid, guest)
return uid, guest, nil
c.store(token, uid, guest, platform)
return uid, guest, platform, nil
}
// Invalidate drops a token from the cache (e.g. after a revoke).
@@ -70,26 +72,26 @@ func (c *Cache) Invalidate(token string) {
delete(c.entries, token)
}
// lookup returns a live cached account id and guest flag for token.
func (c *Cache) lookup(token string) (string, bool, bool) {
// lookup returns a live cached account id, guest flag and platform for token.
func (c *Cache) lookup(token string) (string, bool, string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[token]
if !ok || !c.now().Before(e.expires) {
return "", false, false
return "", false, "", false
}
return e.userID, e.isGuest, true
return e.userID, e.isGuest, e.platform, true
}
// store caches token -> (userID, isGuest), sweeping expired entries and bounding
// the size.
func (c *Cache) store(token, userID string, isGuest bool) {
// store caches token -> (userID, isGuest, platform), sweeping expired entries and
// bounding the size.
func (c *Cache) store(token, userID string, isGuest bool, platform string) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.entries) >= c.max {
c.evictLocked()
}
c.entries[token] = entry{userID: userID, isGuest: isGuest, expires: c.now().Add(c.ttl)}
c.entries[token] = entry{userID: userID, isGuest: isGuest, platform: platform, expires: c.now().Add(c.ttl)}
}
// evictLocked removes expired entries and, if still at capacity, drops arbitrary
+20 -19
View File
@@ -8,18 +8,19 @@ import (
)
type fakeResolver struct {
uid string
guest bool
err error
calls int
uid string
guest bool
platform string
err error
calls int
}
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, bool, error) {
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, bool, string, error) {
f.calls++
if f.err != nil {
return "", false, f.err
return "", false, "", f.err
}
return f.uid, f.guest, nil
return f.uid, f.guest, f.platform, nil
}
func TestResolveCachesBackendHit(t *testing.T) {
@@ -27,7 +28,7 @@ func TestResolveCachesBackendHit(t *testing.T) {
c := NewCache(r, time.Minute, 10)
for i := 0; i < 3; i++ {
uid, _, err := c.Resolve(context.Background(), "tok")
uid, _, _, err := c.Resolve(context.Background(), "tok")
if err != nil || uid != "user-1" {
t.Fatalf("resolve #%d = (%q, %v)", i, uid, err)
}
@@ -37,24 +38,24 @@ func TestResolveCachesBackendHit(t *testing.T) {
}
}
func TestResolveCarriesAndCachesGuestFlag(t *testing.T) {
r := &fakeResolver{uid: "guest-1", guest: true}
func TestResolveCarriesAndCachesGuestFlagAndPlatform(t *testing.T) {
r := &fakeResolver{uid: "guest-1", guest: true, platform: "vk/ios"}
c := NewCache(r, time.Minute, 10)
for i := 0; i < 2; i++ {
uid, guest, err := c.Resolve(context.Background(), "tok")
if err != nil || uid != "guest-1" || !guest {
t.Fatalf("resolve #%d = (%q, guest=%v, %v)", i, uid, guest, err)
uid, guest, platform, err := c.Resolve(context.Background(), "tok")
if err != nil || uid != "guest-1" || !guest || platform != "vk/ios" {
t.Fatalf("resolve #%d = (%q, guest=%v, platform=%q, %v)", i, uid, guest, platform, err)
}
}
if r.calls != 1 {
t.Fatalf("backend calls = %d, want 1 (guest flag cached)", r.calls)
t.Fatalf("backend calls = %d, want 1 (guest flag + platform cached)", r.calls)
}
}
func TestResolvePropagatesBackendError(t *testing.T) {
r := &fakeResolver{err: errors.New("nope")}
c := NewCache(r, time.Minute, 10)
if _, _, err := c.Resolve(context.Background(), "tok"); err == nil {
if _, _, _, err := c.Resolve(context.Background(), "tok"); err == nil {
t.Fatal("expected backend error to propagate")
}
}
@@ -65,11 +66,11 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
base := time.Now()
c.now = func() time.Time { return base }
if _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
if _, _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
t.Fatal(err)
}
c.now = func() time.Time { return base.Add(2 * time.Minute) } // past TTL
if _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
if _, _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
t.Fatal(err)
}
if r.calls != 2 {
@@ -80,9 +81,9 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
func TestInvalidateForcesReResolve(t *testing.T) {
r := &fakeResolver{uid: "user-1"}
c := NewCache(r, time.Minute, 10)
_, _, _ = c.Resolve(context.Background(), "tok")
_, _, _, _ = c.Resolve(context.Background(), "tok")
c.Invalidate("tok")
_, _, _ = c.Resolve(context.Background(), "tok")
_, _, _, _ = c.Resolve(context.Background(), "tok")
if r.calls != 2 {
t.Fatalf("backend calls = %d, want 2 after invalidate", r.calls)
}
+8 -6
View File
@@ -206,7 +206,7 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
if q, perr := url.ParseQuery(initData); perr == nil {
startParam = q.Get("start_param")
}
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam)
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam, string(in.Subtype()))
if err != nil {
return nil, err
}
@@ -225,7 +225,7 @@ func authVKHandler(backend *backendclient.Client, secret string) Handler {
if err != nil {
return nil, err
}
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()))
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()), user.Subtype())
if err != nil {
return nil, err
}
@@ -238,11 +238,13 @@ func authGuestHandler(backend *backendclient.Client) Handler {
// The guest bootstrap historically carried no payload; the detected zone is
// optional, so an absent or empty one simply yields no time-zone seed (rather
// than panicking in GetRootAs* on a zero-length buffer).
var browserTz string
var browserTz, subtype string
if len(req.Payload) > 0 {
browserTz = string(fb.GetRootAsGuestLoginRequest(req.Payload, 0).BrowserTz())
g := fb.GetRootAsGuestLoginRequest(req.Payload, 0)
browserTz = string(g.BrowserTz())
subtype = string(g.Subtype())
}
sess, err := backend.GuestAuth(ctx, browserTz)
sess, err := backend.GuestAuth(ctx, browserTz, subtype)
if err != nil {
return nil, err
}
@@ -263,7 +265,7 @@ func authEmailRequestHandler(backend *backendclient.Client) Handler {
func authEmailLoginHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailLoginRequest(req.Payload, 0)
sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code()))
sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code()), string(in.Subtype()))
if err != nil {
return nil, err
}
+20 -2
View File
@@ -22,10 +22,28 @@ var ErrInvalid = errors.New("vkauth: invalid vk launch params")
// Identity is the user extracted from verified VK launch params. ExternalID is the
// vk_user_id used as the identities external_id; Language is the vk_language hint that
// seeds a brand-new account's preferred language.
// seeds a brand-new account's preferred language; Platform is the raw signed
// vk_platform value (e.g. "mobile_iphone", "desktop_web"), from which Subtype derives
// the trusted device family.
type Identity struct {
ExternalID string
Language string
Platform string
}
// Subtype maps the signed vk_platform to the trusted device family recorded on the
// session — ios (iPhone/iPad, the store-frozen case), android, or web (desktop/mobile
// web and anything unrecognised). Because vk_platform rides inside the verified
// signature, this subtype is trustworthy, unlike a client-reported one.
func (i Identity) Subtype() string {
switch {
case strings.Contains(i.Platform, "android"):
return "android"
case strings.Contains(i.Platform, "iphone"), strings.Contains(i.Platform, "ipad"):
return "ios"
default:
return "web"
}
}
// Verify checks the `sign` of a VK Mini App launch query string against secret and
@@ -68,5 +86,5 @@ func Verify(params, secret string) (Identity, error) {
if externalID == "" {
return Identity{}, ErrInvalid
}
return Identity{ExternalID: externalID, Language: values.Get("vk_language")}, nil
return Identity{ExternalID: externalID, Language: values.Get("vk_language"), Platform: values.Get("vk_platform")}, nil
}
+27
View File
@@ -47,6 +47,33 @@ func TestVerifyValid(t *testing.T) {
if id.ExternalID != "494075" || id.Language != "ru" {
t.Fatalf("identity = %+v, want ExternalID=494075 Language=ru", id)
}
if id.Platform != "android" || id.Subtype() != "android" {
t.Fatalf("platform = %q subtype = %q, want android/android", id.Platform, id.Subtype())
}
}
// TestSubtype locks the vk_platform -> device-family mapping (the trusted subtype),
// notably that iPhone and iPad both surface as the store-frozen "ios" and that any
// unrecognised or empty value falls back to web.
func TestSubtype(t *testing.T) {
for _, tc := range []struct {
platform string
want string
}{
{"mobile_iphone", "ios"},
{"mobile_ipad", "ios"},
{"mobile_iphone_messenger", "ios"},
{"mobile_android", "android"},
{"android", "android"},
{"desktop_web", "web"},
{"mobile_web", "web"},
{"", "web"},
{"something_new", "web"},
} {
if got := (vkauth.Identity{Platform: tc.platform}).Subtype(); got != tc.want {
t.Errorf("Subtype(%q) = %q, want %q", tc.platform, got, tc.want)
}
}
}
// TestVerifyCommaValue locks the URL-encoding of the one realistic special-char VK