feat(account): VK ID web login to link a VK identity from a browser
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s

A browser has no signed VK Mini App launch params, so linking VK on the web uses
VK ID's raw OAuth 2.1 flow (PKCE, no @vkid/sdk): the SPA redirects to VK's hosted
login and returns with an authorization code, which the gateway exchanges
server-side (confidential, under the VK "Web" app's protected key) for the trusted
vk user id — then the existing link/merge machinery attaches or merges it.

- fbs LinkVKRequest{code, device_id, code_verifier}; codec + TS bindings.
- backend link.Service ConfirmVK/MergeVK/attachVK (KindVK, mirror Telegram),
  handleLinkVK[Merge], routes /user/link/vk[/merge], backendclient LinkVK[Merge].
- gateway internal/vkid confidential code exchange (id.vk.com/oauth2/auth);
  transcode link.vk.confirm/merge (registered only when configured) + config
  GATEWAY_VK_ID_{APP_ID,CLIENT_SECRET,REDIRECT_URL} + main wiring.
- UI lib/vkid (PKCE authorize redirect + callback), Profile "Link VK" control,
  boot callback handling; a merge re-authorizes for a fresh code (VK codes are
  single-use). Web-only (a redirect strands a Mini App webview).
- Deploy: VITE_VK_APP_ID + VITE_VK_ID_REDIRECT_URL build args + gateway env,
  ci.yaml/prod-deploy TEST_/PROD_ vars, compose/Dockerfile/.env.example/README.
- Tests: vkid exchange unit (string/number user_id, id_token fallback, errors),
  transcode link.vk, backend ConfirmVK/MergeVK inttest, codec encodeLinkVK.
- Docs: ARCHITECTURE §4, FUNCTIONAL(+ru), gateway README.
This commit is contained in:
Ilia Denisov
2026-07-03 17:59:33 +02:00
parent 60faa4f064
commit 2c465c01d2
36 changed files with 1131 additions and 16 deletions
+8
View File
@@ -330,6 +330,10 @@ jobs:
# VK Mini App protected key (offline HMAC for the launch-param signature); empty # VK Mini App protected key (offline HMAC for the launch-param signature); empty
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret. # leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }} GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
# VK ID web login (browser VK-identity linking): the VK ID "Web" app's protected key
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
# App above. Empty leaves the link.vk.* ops disabled.
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.TEST_GATEWAY_VK_ID_CLIENT_SECRET }}
# Signs the finished-game export download URLs (backend + compose interpolation). # Signs the finished-game export download URLs (backend + compose interpolation).
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }} EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay. Empty host leaves the # Transactional email via the shared Selectel relay. Empty host leaves the
@@ -369,6 +373,10 @@ jobs:
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
VITE_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }} VITE_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }}
# VK ID web login: the "Web" app id + its trusted redirect URL. One value each feeds
# both the SPA (the authorize URL) and the gateway (client_id / exchange redirect_uri).
VITE_VK_APP_ID: ${{ vars.TEST_VITE_VK_APP_ID }}
VITE_VK_ID_REDIRECT_URL: ${{ vars.TEST_VK_ID_REDIRECT_URL }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
# Unset vars render empty -> the compose ":-" defaults apply. # Unset vars render empty -> the compose ":-" defaults apply.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
+13
View File
@@ -41,6 +41,9 @@ jobs:
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
VITE_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }} VITE_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }}
# VK ID web login: the "Web" app id + trusted redirect URL, baked into the SPA authorize URL.
VITE_VK_APP_ID: ${{ vars.PROD_VITE_VK_APP_ID }}
VITE_VK_ID_REDIRECT_URL: ${{ vars.PROD_VK_ID_REDIRECT_URL }}
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }} VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }} POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
@@ -84,6 +87,11 @@ jobs:
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }} TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }} GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
# VK ID web login: the SPA app id + redirect URL (the gateway reuses them as its
# client_id / exchange redirect_uri at runtime) and the "Web" app's protected key.
VITE_VK_APP_ID: ${{ vars.PROD_VITE_VK_APP_ID }}
VITE_VK_ID_REDIRECT_URL: ${{ vars.PROD_VK_ID_REDIRECT_URL }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.PROD_GATEWAY_VK_ID_CLIENT_SECRET }}
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY). # Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }} EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay (confirm-codes). # Transactional email via the shared Selectel relay (confirm-codes).
@@ -167,6 +175,11 @@ jobs:
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN' export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL' export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET' export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
# VK ID web login: needed at runtime — the gateway's GATEWAY_VK_ID_* env resolves
# its app id / redirect URL from these VITE_ values (one source, shared with the SPA).
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY' export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY'
export SMTP_RELAY_HOST='$SMTP_RELAY_HOST' export SMTP_RELAY_HOST='$SMTP_RELAY_HOST'
export SMTP_RELAY_PORT='$SMTP_RELAY_PORT' export SMTP_RELAY_PORT='$SMTP_RELAY_PORT'
+61
View File
@@ -355,3 +355,64 @@ func TestAccountLinkGuestInversion(t *testing.T) {
t.Errorf("email owner = %s, want durable", owner) t.Errorf("email owner = %s, want durable", owner)
} }
} }
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
links := newLinkService(&capturingMailer{})
guest := provisionGuest(t)
vkID := "vk-" + uuid.NewString()
res, err := links.ConfirmVK(ctx, guest, vkID)
if err != nil {
t.Fatalf("confirm vk: %v", err)
}
if !res.Linked || res.MergeRequired {
t.Fatalf("confirm = %+v, want linked", res)
}
if acc, _ := store.GetByID(ctx, guest); acc.IsGuest {
t.Error("guest flag should clear once VK is linked")
}
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); !ok || owner != guest {
t.Errorf("vk owner = %s, want the promoted guest %s", owner, guest)
}
}
// TestAccountLinkVKMergeIntoCaller merges the account owning a VK identity into the
// current durable account: ConfirmVK previews the merge, MergeVK folds it, the caller
// stays primary and keeps its session, and the VK identity repoints to the caller.
func TestAccountLinkVKMergeIntoCaller(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
links := newLinkService(&capturingMailer{})
caller := provisionAccount(t)
other := provisionAccount(t)
vkID := "vk-" + uuid.NewString()
if err := store.AttachIdentity(ctx, other, account.KindVK, vkID, true); err != nil {
t.Fatalf("seed vk on other: %v", err)
}
confirm, err := links.ConfirmVK(ctx, caller, vkID)
if err != nil {
t.Fatalf("confirm vk: %v", err)
}
if !confirm.MergeRequired || confirm.SecondaryID != other {
t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
}
merge, err := links.MergeVK(ctx, caller, vkID)
if err != nil {
t.Fatalf("merge vk: %v", err)
}
if merge.PrimaryID != caller || merge.SwitchedToken != "" {
t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
}
if mergedInto(t, other) != caller {
t.Error("other should be tombstoned into caller")
}
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); owner != caller {
t.Errorf("vk owner = %s, want caller after merge", owner)
}
}
+47
View File
@@ -133,6 +133,53 @@ func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, extern
return s.accounts.ClearGuest(ctx, callerID) return s.accounts.ClearGuest(ctx, callerID)
} }
// ConfirmVK attaches a gateway-validated VK identity to the caller (Linked) or
// reports that it belongs to another account (MergeRequired). The gateway has already
// completed the VK ID code exchange, so externalID is the trusted vk user id.
func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID string) (ConfirmResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
if err != nil {
return ConfirmResult{}, err
}
if !ok {
if err := s.attachVK(ctx, callerID, externalID); err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Linked: true}, nil
}
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
// (subject to the guest-primary rule).
func (s *Service) MergeVK(ctx context.Context, callerID uuid.UUID, externalID string) (MergeResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
if err != nil {
return MergeResult{}, err
}
if !ok {
if err := s.attachVK(ctx, callerID, externalID); err != nil {
return MergeResult{}, err
}
return MergeResult{PrimaryID: callerID}, nil
}
if owner == callerID {
return MergeResult{PrimaryID: callerID}, nil
}
return s.merge(ctx, callerID, owner)
}
// attachVK links the identity to the caller and promotes a guest.
func (s *Service) attachVK(ctx context.Context, callerID uuid.UUID, externalID string) error {
if err := s.accounts.AttachIdentity(ctx, callerID, account.KindVK, externalID, true); err != nil {
return err
}
return s.accounts.ClearGuest(ctx, callerID)
}
// merge decides the primary (the caller, unless it is a guest and the other is // merge decides the primary (the caller, unless it is a guest and the other is
// durable), runs the data merge, retires the secondary's sessions and mints a new // durable), runs the data merge, retires the secondary's sessions and mints a new
// session when the active account switches. // session when the active account switches.
+2
View File
@@ -74,6 +74,8 @@ func (s *Server) registerRoutes() {
u.POST("/link/email/merge", s.handleLinkEmailMerge) u.POST("/link/email/merge", s.handleLinkEmailMerge)
u.POST("/link/telegram", s.handleLinkTelegram) u.POST("/link/telegram", s.handleLinkTelegram)
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge) u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
u.POST("/link/vk", s.handleLinkVK)
u.POST("/link/vk/merge", s.handleLinkVKMerge)
u.POST("/link/unlink", s.handleUnlink) u.POST("/link/unlink", s.handleUnlink)
// Change the account's confirmed email: mail a code to the new address, then // Change the account's confirmed email: mail a code to the new address, then
// atomically switch on confirm (a new address owned by another account is // atomically switch on confirm (a new address owned by another account is
+48
View File
@@ -34,6 +34,12 @@ type linkTelegramBody struct {
ExternalID string `json:"external_id"` ExternalID string `json:"external_id"`
} }
// linkVKBody carries a gateway-validated VK identity (the trusted vk user id the
// gateway resolved from the VK ID code exchange).
type linkVKBody struct {
ExternalID string `json:"external_id"`
}
// linkResultResponse is the unified result of a confirm or merge step. Status is // linkResultResponse is the unified result of a confirm or merge step. Status is
// "linked" (bound to the caller), "merge_required" (the identity belongs to another // "linked" (bound to the caller), "merge_required" (the identity belongs to another
// account — the secondary_* fields summarise it for the irreversible confirmation), // account — the secondary_* fields summarise it for the irreversible confirmation),
@@ -233,6 +239,48 @@ func (s *Server) handleLinkTelegramMerge(c *gin.Context) {
c.JSON(http.StatusOK, s.mergeResultResponse(c, res)) c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
} }
// handleLinkVK attaches a gateway-validated VK identity to the caller or reports a
// required merge.
func (s *Server) handleLinkVK(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkVKBody
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "missing external_id")
return
}
res, err := s.links.ConfirmVK(c.Request.Context(), uid, req.ExternalID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
}
// handleLinkVKMerge merges the account owning a gateway-validated VK identity into
// the caller's.
func (s *Server) handleLinkVKMerge(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkVKBody
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "missing external_id")
return
}
res, err := s.links.MergeVK(c.Request.Context(), uid, req.ExternalID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
}
// confirmResultResponse renders a confirm step: a merge preview (secondary summary) // confirmResultResponse renders a confirm step: a merge preview (secondary summary)
// or a completed link (the active account's refreshed profile). // or a completed link (the active account's refreshed profile).
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse { func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
+6
View File
@@ -68,6 +68,8 @@ VITE_TELEGRAM_BOT_ID=
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>) VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>) VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
VITE_VK_ID_REDIRECT_URL= # VK ID trusted redirect URL (e.g. https://erudit-game.ru/app/); also the gateway's exchange redirect_uri
VITE_GATEWAY_URL= VITE_GATEWAY_URL=
# --- Grafana ---------------------------------------------------------------- # --- Grafana ----------------------------------------------------------------
@@ -97,3 +99,7 @@ TELEGRAM_API_BASE_URL=
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call). # launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret. # Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
GATEWAY_VK_APP_SECRET= GATEWAY_VK_APP_SECRET=
# VK ID web login (browser VK-identity linking) — a SEPARATE VK "Web" app: the gateway runs
# the confidential OAuth 2.1 code exchange under this protected key. The app id + redirect URL
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
GATEWAY_VK_ID_CLIENT_SECRET=
+5 -2
View File
@@ -95,6 +95,9 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). | | `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). |
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). | | `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
| `VITE_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). | | `VITE_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). |
| `VITE_VK_APP_ID` | variable | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. |
| `VITE_VK_ID_REDIRECT_URL` | variable | _(empty)_ | VK ID trusted redirect URL (e.g. `https://erudit-game.ru/app/`) — must match the app's registered redirect exactly. Used by the SPA and as the gateway's exchange `redirect_uri`. |
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
| `SMTP_RELAY_HOST` | variable | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). | | `SMTP_RELAY_HOST` | variable | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
| `SMTP_RELAY_PORT` | variable | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). | | `SMTP_RELAY_PORT` | variable | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
@@ -212,11 +215,11 @@ players arrive.
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN, `PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA, TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY, BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY,
SMTP_RELAY_USER, SMTP_RELAY_PASS}`; variables: SMTP_RELAY_USER, SMTP_RELAY_PASS, GATEWAY_VK_ID_CLIENT_SECRET}`; variables:
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, `PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID, GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK, VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK, VITE_VK_APP_ID, VITE_VK_ID_REDIRECT_URL,
SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, SMTP_RELAY_FROM, PUBLIC_BASE_URL, SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, SMTP_RELAY_FROM, PUBLIC_BASE_URL,
SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL, SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL,
GRAFANA_SMTP_PORT, GF_SMTP_ENABLED}`. GRAFANA_SMTP_PORT, GF_SMTP_ENABLED}`.
+12
View File
@@ -190,6 +190,8 @@ services:
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-} VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev} VITE_APP_VERSION: ${APP_VERSION:-dev}
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag). # Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
@@ -207,6 +209,14 @@ services:
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables # app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
# the VK auth path (auth.vk is then unregistered). # the VK auth path (auth.vk is then unregistered).
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-} GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
# VK ID web login (browser VK-identity linking): the confidential OAuth 2.1 code
# exchange runs server-side under the VK ID "Web" app's protected key. This is a
# SEPARATE VK app from the Mini App above, so the credentials are distinct. The app id
# and redirect URL are the same values the SPA builds its authorize URL from (one
# source each). All three empty disables the link.vk.* ops.
GATEWAY_VK_ID_APP_ID: ${VITE_VK_APP_ID:-}
GATEWAY_VK_ID_CLIENT_SECRET: ${GATEWAY_VK_ID_CLIENT_SECRET:-}
GATEWAY_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay # The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
# reaches the gateway at :9092 (plaintext, internal). In the test contour both # reaches the gateway at :9092 (plaintext, internal). In the test contour both
# listeners stay on the internal network (the bot shares the VPN netns); in prod # listeners stay on the internal network (the bot shares the VPN netns); in prod
@@ -264,6 +274,8 @@ services:
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-} VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev} VITE_APP_VERSION: ${APP_VERSION:-dev}
restart: unless-stopped restart: unless-stopped
+10 -2
View File
@@ -257,11 +257,19 @@ arrive from a platform rather than completing a mandatory registration).
control of the identity before attaching it: **email** through the confirm-code control of the identity before attaching it: **email** through the confirm-code
flow, **Telegram** through the web **Login Widget** (validated by the validator, flow, **Telegram** through the web **Login Widget** (validated by the validator,
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
passes the trusted `external_id` to the backend, as for `auth.telegram`). The passes the trusted `external_id` to the backend, as for `auth.telegram`), and **VK**
through **VK ID web login** (`gateway/internal/vkid`): the browser runs the VK ID raw
OAuth 2.1 flow with PKCE — a full-page redirect to VK's hosted login, no `@vkid/sdk`
and the gateway completes the **confidential code exchange** server-side under the VK
"Web" app's protected key (a distinct VK app from the Mini App) to obtain the trusted
`external_id`. A browser has no signed launch parameters, so unlike the VK Mini App
(offline HMAC, §12) this makes an outbound call to `id.vk.com`, and it is web-only — a
full-page redirect would strand a native Mini App webview. The
request step **always** sends/accepts the proof (no pre-send "already taken" request step **always** sends/accepts the proof (no pre-send "already taken"
signal, so a probe cannot enumerate registered addresses); a required **merge** signal, so a probe cannot enumerate registered addresses); a required **merge**
is revealed **only after** the proof is verified and is performed behind an is revealed **only after** the proof is verified and is performed behind an
explicit, irreversible confirmation. A free identity is simply attached (and a explicit, irreversible confirmation (for VK, whose authorization code is single-use,
the merge re-authorizes for a fresh code). A free identity is simply attached (and a
guest is promoted to durable, clearing `is_guest`). guest is promoted to durable, clearing `is_guest`).
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The - **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
backend **refuses removing the last identity** (`ErrLastIdentity`), so an account backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
+4 -2
View File
@@ -108,8 +108,10 @@ account is kept and the guest's games move into it. A merge is blocked only whil
two accounts share a game still in progress. two accounts share a game still in progress.
The profile lists the account's **sign-in methods**. On the web a player can add The profile lists the account's **sign-in methods**. On the web a player can add
Telegram; adding VK on the web is not offered yet, and inside a Mini App the host Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
platform is already linked. A linked provider can be **unlinked** — except the last back); inside a Mini App the host platform is already linked. Linking a provider that
already belongs to another account offers the same irreversible **merge** as email
linking. A linked provider can be **unlinked** — except the last
remaining sign-in method, which is refused so the account stays reachable. **Email is remaining sign-in method, which is refused so the account stays reachable. **Email is
never unlinked; it is changed**: the player enters a new address, confirms a code never unlinked; it is changed**: the player enters a new address, confirms a code
mailed to it, and the account switches to it atomically, freeing the old address. A new mailed to it, and the account switches to it atomically, freeing the old address. A new
+4 -2
View File
@@ -112,8 +112,10 @@ Telegram держит **единого бота**: все игроки поль
запрещено, только пока у аккаунтов есть общая незавершённая игра. запрещено, только пока у аккаунтов есть общая незавершённая игра.
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram; добавление VK в вебе пока не предлагается, а внутри Mini App платформа-хозяин Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
уже привязана. Привязанного провайдера можно **отвязать** — кроме последнего обратно); внутри Mini App платформа-хозяин уже привязана. Привязка провайдера, уже
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
email. Привязанного провайдера можно **отвязать** — кроме последнего
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым. оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код, **Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
+4
View File
@@ -25,12 +25,16 @@ ARG VITE_TELEGRAM_BOT_ID=
ARG VITE_TELEGRAM_LINK= ARG VITE_TELEGRAM_LINK=
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME= ARG VITE_TELEGRAM_GAME_CHANNEL_NAME=
ARG VITE_VK_APP_LINK= ARG VITE_VK_APP_LINK=
ARG VITE_VK_APP_ID=
ARG VITE_VK_ID_REDIRECT_URL=
ARG VITE_GATEWAY_URL= ARG VITE_GATEWAY_URL=
ARG VITE_APP_VERSION= ARG VITE_APP_VERSION=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \ VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
VITE_VK_APP_LINK=$VITE_VK_APP_LINK \ VITE_VK_APP_LINK=$VITE_VK_APP_LINK \
VITE_VK_APP_ID=$VITE_VK_APP_ID \
VITE_VK_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_URL \
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
VITE_APP_VERSION=$VITE_APP_VERSION VITE_APP_VERSION=$VITE_APP_VERSION
+11 -3
View File
@@ -61,6 +61,12 @@ round-trip, then forwards the trusted `vk_user_id` (and the client-read display
from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled
(`auth.vk` is unregistered). (`auth.vk` is unregistered).
`link.vk.confirm`/`merge` link a VK identity from a **browser** (not a Mini App) via **VK ID web
login** (`internal/vkid`): the SPA runs the VK ID raw OAuth 2.1 flow (PKCE, no `@vkid/sdk`) and
the gateway completes the **confidential code exchange** at `id.vk.com` under a SEPARATE VK "Web"
app's protected key — the only op here that calls VK (the Mini App path above is offline). All
three `GATEWAY_VK_ID_*` unset leaves the ops unregistered.
The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`, The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`,
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`, `auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops; `game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
@@ -72,9 +78,10 @@ refetch). The social/account/history ops —
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`, `blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
`stats.get`, `game.gcg`, and the `notify` live event — go through the identical `stats.get`, `game.gcg`, and the `notify` live event — go through the identical
transcode pattern (`transcode_social.go`). Account linking & merge transcode pattern (`transcode_social.go`). Account linking & merge
`link.email.request/confirm/merge` and `link.telegram.confirm/merge` `link.email.request/confirm/merge`, `link.telegram.confirm/merge` and
(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the `link.vk.confirm/merge` (`transcode_link.go`); the telegram ops validate the **Login
validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These Widget** payload via the validator (`ValidateLoginWidget`), the vk ops complete the VK ID
web code exchange via `internal/vkid`, and both forward the trusted `external_id`. These
**superseded** the former `email.bind.*` ops, which were removed. **superseded** the former `email.bind.*` ops, which were removed.
## Configuration ## Configuration
@@ -89,6 +96,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` | | `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
| `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) | | `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
| `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) | | `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) |
| `GATEWAY_VK_ID_APP_ID` / `GATEWAY_VK_ID_CLIENT_SECRET` / `GATEWAY_VK_ID_REDIRECT_URL` | unset | VK ID "Web" app credentials for VK web-login linking (`link.vk.*`): the server-side confidential OAuth 2.1 code exchange. A separate VK app from `GATEWAY_VK_APP_SECRET`; all three required to enable the ops |
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) | | `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay | | `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) | | `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
+10 -1
View File
@@ -33,6 +33,7 @@ import (
"scrabble/gateway/internal/ratelimit" "scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session" "scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode" "scrabble/gateway/internal/transcode"
"scrabble/gateway/internal/vkid"
"scrabble/pkg/mtls" "scrabble/pkg/mtls"
botlinkv1 "scrabble/pkg/proto/botlink/v1" botlinkv1 "scrabble/pkg/proto/botlink/v1"
telegramv1 "scrabble/pkg/proto/telegram/v1" telegramv1 "scrabble/pkg/proto/telegram/v1"
@@ -190,7 +191,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)") logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
} }
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret)) // VK ID web login (browser VK-identity linking) is optional: build the confidential
// code-exchanger only when fully configured, else leave the interface nil so the
// link.vk.* ops stay unregistered.
var vkidExchanger transcode.VKIDExchanger
if cfg.VKID.Enabled() {
vkidExchanger = vkid.New(cfg.VKID.AppID, cfg.VKID.ClientSecret, cfg.VKID.RedirectURI)
logger.Info("vk id web login enabled")
}
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger))
edge := connectsrv.NewServer(connectsrv.Deps{ edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry, Registry: registry,
Sessions: sessions, Sessions: sessions,
@@ -335,6 +335,24 @@ func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID strin
return out, err return out, err
} }
// LinkVK attaches a gateway-validated VK identity to the caller or reports a required
// merge. externalID is the trusted vk user id resolved from the VK ID code exchange.
func (c *Client) LinkVK(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
var out LinkResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/vk", userID, "",
map[string]string{"external_id": externalID}, &out)
return out, err
}
// LinkVKMerge merges the account owning a gateway-validated VK identity into the
// caller's.
func (c *Client) LinkVKMerge(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
var out LinkResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/vk/merge", userID, "",
map[string]string{"external_id": externalID}, &out)
return out, err
}
// ChangeEmailRequest asks the backend to mail a confirm-code to a new address for an // ChangeEmailRequest asks the backend to mail a confirm-code to a new address for an
// authenticated email change. // authenticated email change.
func (c *Client) ChangeEmailRequest(ctx context.Context, userID, email string) error { func (c *Client) ChangeEmailRequest(ctx context.Context, userID, email string) error {
+26
View File
@@ -36,6 +36,11 @@ type Config struct {
// VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API // VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered). // round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
VKAppSecret string VKAppSecret string
// VKID configures the VK ID web login used to link a VK identity from a browser
// (the confidential OAuth 2.1 code exchange against id.vk.com). It belongs to a
// separate VK "Web" app from VKAppSecret's Mini App, so its credentials are distinct.
// Any field empty disables the VK web-link ops (link.vk.*).
VKID VKIDConfig
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An // BotLink configures the reverse mTLS channel to the remote Telegram bot. An
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay). // empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
BotLink BotLinkConfig BotLink BotLinkConfig
@@ -161,6 +166,22 @@ func DefaultAbuse() AbuseConfig {
} }
} }
// VKIDConfig holds the VK ID web-login credentials for the confidential
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
// with the app and the one the frontend uses. All three are required to enable the flow.
type VKIDConfig struct {
AppID string
ClientSecret string
RedirectURI string
}
// Enabled reports whether VK ID web login is fully configured. When false the gateway
// leaves the VK web-link ops (link.vk.*) unregistered.
func (c VKIDConfig) Enabled() bool {
return c.AppID != "" && c.ClientSecret != "" && c.RedirectURI != ""
}
// Load reads the configuration from the environment, applies defaults, and // Load reads the configuration from the environment, applies defaults, and
// validates the result. // validates the result.
func Load() (Config, error) { func Load() (Config, error) {
@@ -174,6 +195,11 @@ func Load() (Config, error) {
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"), AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"), ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"), VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
VKID: VKIDConfig{
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
RedirectURI: os.Getenv("GATEWAY_VK_ID_REDIRECT_URL"),
},
SessionCacheMax: defaultSessionCacheMax, SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(), RateLimit: DefaultRateLimit(),
Abuse: DefaultAbuse(), Abuse: DefaultAbuse(),
+13
View File
@@ -14,6 +14,7 @@ import (
"scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/connector" "scrabble/gateway/internal/connector"
"scrabble/gateway/internal/vkauth" "scrabble/gateway/internal/vkauth"
"scrabble/gateway/internal/vkid"
fb "scrabble/pkg/fbs/scrabblefb" fb "scrabble/pkg/fbs/scrabblefb"
) )
@@ -152,6 +153,15 @@ func WithVKAuth(secret string) Option {
} }
} }
// WithVKLink registers the VK web-link ops (link.vk.confirm/merge), which complete a
// browser VK ID authorization via the server-side code exchange. A nil exchanger leaves
// them unregistered, so the ops are unknown wherever VK ID web login is not configured.
func WithVKLink(ex VKIDExchanger) Option {
return func(r *Registry, backend *backendclient.Client) {
registerVKLinkOps(r, backend, ex)
}
}
// Lookup returns the operation for messageType, and whether it is registered. // Lookup returns the operation for messageType, and whether it is registered.
func (r *Registry) Lookup(messageType string) (Op, bool) { func (r *Registry) Lookup(messageType string) (Op, bool) {
op, ok := r.ops[messageType] op, ok := r.ops[messageType]
@@ -172,6 +182,9 @@ func DomainCode(err error) (string, bool) {
if errors.Is(err, vkauth.ErrInvalid) { if errors.Is(err, vkauth.ErrInvalid) {
return "invalid_vk_params", true return "invalid_vk_params", true
} }
if errors.Is(err, vkid.ErrInvalid) {
return "invalid_vk_id", true
}
if errors.Is(err, connector.ErrInvalidLoginWidget) { if errors.Is(err, connector.ErrInvalidLoginWidget) {
return "invalid_login_widget", true return "invalid_login_widget", true
} }
@@ -4,6 +4,7 @@ import (
"context" "context"
"scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/vkid"
fb "scrabble/pkg/fbs/scrabblefb" fb "scrabble/pkg/fbs/scrabblefb"
) )
@@ -18,6 +19,8 @@ const (
MsgLinkEmailMerge = "link.email.merge" MsgLinkEmailMerge = "link.email.merge"
MsgLinkTelegram = "link.telegram.confirm" MsgLinkTelegram = "link.telegram.confirm"
MsgLinkTelegramMerge = "link.telegram.merge" MsgLinkTelegramMerge = "link.telegram.merge"
MsgLinkVK = "link.vk.confirm"
MsgLinkVKMerge = "link.vk.merge"
MsgLinkUnlink = "link.unlink" MsgLinkUnlink = "link.unlink"
MsgEmailChangeRequest = "link.email.change.request" MsgEmailChangeRequest = "link.email.change.request"
MsgEmailChangeConfirm = "link.email.change.confirm" MsgEmailChangeConfirm = "link.email.change.confirm"
@@ -154,3 +157,44 @@ func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, me
return encodeLinkResult(res), nil return encodeLinkResult(res), nil
} }
} }
// VKIDExchanger completes a VK ID web authorization-code exchange, returning the
// launching user's trusted VK identity. It is satisfied by *vkid.Exchanger and lets the
// VK web-link ops resolve a browser VK login, which — unlike the Mini App path — has no
// offline launch signature to verify.
type VKIDExchanger interface {
Exchange(ctx context.Context, code, deviceID, codeVerifier string) (vkid.Identity, error)
}
// registerVKLinkOps adds the VK web-link ops when a VK ID exchanger is configured; a nil
// exchanger leaves them unregistered (VK ID web login not configured).
func registerVKLinkOps(r *Registry, backend *backendclient.Client, ex VKIDExchanger) {
if ex == nil {
return
}
r.ops[MsgLinkVK] = Op{Handler: linkVKHandler(backend, ex, false), Auth: true}
r.ops[MsgLinkVKMerge] = Op{Handler: linkVKHandler(backend, ex, true), Auth: true}
}
// linkVKHandler completes the VK ID code exchange (server-side, under the app's
// protected key) and then calls the backend's link or merge endpoint with the trusted
// VK external id.
func linkVKHandler(backend *backendclient.Client, ex VKIDExchanger, merge bool) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkVKRequest(req.Payload, 0)
user, err := ex.Exchange(ctx, string(in.Code()), string(in.DeviceId()), string(in.CodeVerifier()))
if err != nil {
return nil, err
}
var res backendclient.LinkResultResp
if merge {
res, err = backend.LinkVKMerge(ctx, req.UserID, user.ExternalID)
} else {
res, err = backend.LinkVK(ctx, req.UserID, user.ExternalID)
}
if err != nil {
return nil, err
}
return encodeLinkResult(res), nil
}
}
@@ -0,0 +1,90 @@
package transcode_test
import (
"context"
"encoding/json"
"net/http"
"testing"
flatbuffers "github.com/google/flatbuffers/go"
"scrabble/gateway/internal/transcode"
"scrabble/gateway/internal/vkid"
fb "scrabble/pkg/fbs/scrabblefb"
)
func linkVKPayload(code, deviceID, verifier string) []byte {
b := flatbuffers.NewBuilder(64)
c := b.CreateString(code)
d := b.CreateString(deviceID)
v := b.CreateString(verifier)
fb.LinkVKRequestStart(b)
fb.LinkVKRequestAddCode(b, c)
fb.LinkVKRequestAddDeviceId(b, d)
fb.LinkVKRequestAddCodeVerifier(b, v)
b.Finish(fb.LinkVKRequestEnd(b))
return b.FinishedBytes()
}
// fakeVKIDExchanger records the exchange inputs and returns a canned identity.
type fakeVKIDExchanger struct {
id vkid.Identity
err error
gotCode, gotDevice, gotVerifier string
}
func (f *fakeVKIDExchanger) Exchange(_ context.Context, code, deviceID, codeVerifier string) (vkid.Identity, error) {
f.gotCode, f.gotDevice, f.gotVerifier = code, deviceID, codeVerifier
return f.id, f.err
}
func TestLinkVKExchangesAndForwards(t *testing.T) {
var gotExternalID string
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/user/link/vk" {
t.Errorf("path = %q", r.URL.Path)
}
var body struct {
ExternalID string `json:"external_id"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
gotExternalID = body.ExternalID
_, _ = w.Write([]byte(`{"status":"linked"}`))
})
defer cleanup()
ex := &fakeVKIDExchanger{id: vkid.Identity{ExternalID: "777"}}
reg := transcode.NewRegistry(backend, nil, transcode.WithVKLink(ex))
op, ok := reg.Lookup(transcode.MsgLinkVK)
if !ok {
t.Fatal("link.vk.confirm not registered")
}
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: linkVKPayload("the-code", "dev-1", "verifier-1")})
if err != nil {
t.Fatalf("handler: %v", err)
}
// The PKCE inputs from the wire must reach the exchanger verbatim...
if ex.gotCode != "the-code" || ex.gotDevice != "dev-1" || ex.gotVerifier != "verifier-1" {
t.Errorf("exchange got %q/%q/%q", ex.gotCode, ex.gotDevice, ex.gotVerifier)
}
// ...and the resolved vk id must be the one forwarded to the backend.
if gotExternalID != "777" {
t.Errorf("backend external_id = %q, want 777", gotExternalID)
}
if string(fb.GetRootAsLinkResult(payload, 0).Status()) != "linked" {
t.Error("expected a linked result")
}
}
func TestLinkVKUnregisteredWithoutExchanger(t *testing.T) {
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
if _, ok := reg.Lookup(transcode.MsgLinkVK); ok {
t.Error("link.vk.confirm must be unregistered when no VK ID exchanger is configured")
}
if _, ok := reg.Lookup(transcode.MsgLinkVKMerge); ok {
t.Error("link.vk.merge must be unregistered when no VK ID exchanger is configured")
}
}
+163
View File
@@ -0,0 +1,163 @@
// Package vkid completes VK ID web authorization on the server. A browser linking a
// VK identity has no signed Mini App launch parameters (that offline HMAC path is
// vkauth); instead the frontend runs the VK ID raw OAuth 2.1 flow (PKCE) and hands the
// gateway the authorization code, which the gateway exchanges — confidentially, under
// the app's protected key — for the launching user's trusted VK id. Unlike the Mini App
// path this makes an outbound call to VK (there is no offline verification for the web
// flow). See docs/ARCHITECTURE.md §12.
package vkid
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
// tokenEndpoint is VK ID's OAuth 2.1 token endpoint. The frontend obtains the
// authorization code against the same host, so the confidential exchange targets it
// too. It is a fixed constant (not user input), so the outbound request carries no
// SSRF risk.
tokenEndpoint = "https://id.vk.com/oauth2/auth"
// exchangeTimeout bounds one code-for-token exchange. Linking is interactive, so an
// unreachable VK must fail fast rather than hold the request open.
exchangeTimeout = 10 * time.Second
// maxResponseBytes caps the token-response read to bound memory on an oversized body.
maxResponseBytes = 1 << 16
)
// ErrInvalid reports an authorization fault: the exchange was rejected or yielded no vk
// user id (a bad or expired code, a mismatched verifier or redirect). It is distinct
// from a transport failure reaching VK, which surfaces as a wrapped error.
var ErrInvalid = errors.New("vkid: vk id authorization exchange failed")
// Identity is the user resolved from a completed VK ID exchange. ExternalID is the vk
// user id, used as the identities external_id.
type Identity struct {
ExternalID string
}
// numericID accepts a VK user id that the token endpoint returns inconsistently as a
// JSON string or a JSON number, normalising both to their decimal string form (empty
// for a null or absent field).
type numericID string
func (n *numericID) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
if s == "null" {
s = ""
}
*n = numericID(s)
return nil
}
// Exchanger completes VK ID confidential authorization-code exchanges for one app.
type Exchanger struct {
appID string
clientSecret string
redirectURI string
endpoint string
httpClient *http.Client
}
// New constructs an Exchanger for the app credentials. redirectURI must equal the
// trusted redirect URL registered with the app and the one the frontend used, or VK
// rejects the exchange.
func New(appID, clientSecret, redirectURI string) *Exchanger {
return &Exchanger{
appID: appID,
clientSecret: clientSecret,
redirectURI: redirectURI,
endpoint: tokenEndpoint,
httpClient: &http.Client{Timeout: exchangeTimeout},
}
}
// Exchange completes the authorization-code grant and returns the trusted vk user id.
// code, deviceID and codeVerifier are the PKCE inputs the frontend obtained from the VK
// ID authorization redirect; the exchange authenticates with the app's protected key.
func (e *Exchanger) Exchange(ctx context.Context, code, deviceID, codeVerifier string) (Identity, error) {
if code == "" || deviceID == "" || codeVerifier == "" {
return Identity{}, ErrInvalid
}
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"code_verifier": {codeVerifier},
"device_id": {deviceID},
"client_id": {e.appID},
"client_secret": {e.clientSecret},
"redirect_uri": {e.redirectURI},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.endpoint, strings.NewReader(form.Encode()))
if err != nil {
return Identity{}, fmt.Errorf("vkid: build exchange request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := e.httpClient.Do(req)
if err != nil {
return Identity{}, fmt.Errorf("vkid: exchange request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return Identity{}, fmt.Errorf("vkid: read exchange response: %w", err)
}
if resp.StatusCode != http.StatusOK {
// VK answers 400 with {error, error_description} on a bad/expired code or a
// verifier/redirect mismatch — an authorization fault, not a transport error.
return Identity{}, ErrInvalid
}
var out struct {
UserID numericID `json:"user_id"`
IDToken string `json:"id_token"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &out); err != nil {
return Identity{}, ErrInvalid
}
if out.Error != "" {
return Identity{}, ErrInvalid
}
uid := string(out.UserID)
if uid == "" || uid == "0" {
uid = subjectFromIDToken(out.IDToken)
}
if uid == "" {
return Identity{}, ErrInvalid
}
return Identity{ExternalID: uid}, nil
}
// subjectFromIDToken extracts the OIDC subject (the vk user id) from the payload of a
// VK ID id_token. The token arrives inside a direct TLS response from VK, so its
// signature is not re-verified here; the claim is only a fallback for a response that
// omits an explicit user_id.
func subjectFromIDToken(idToken string) string {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return ""
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return ""
}
var claims struct {
Sub numericID `json:"sub"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return ""
}
if s := string(claims.Sub); s != "0" {
return s
}
return ""
}
+131
View File
@@ -0,0 +1,131 @@
package vkid
import (
"context"
"encoding/base64"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
// testExchanger builds an Exchanger pointed at a test server.
func testExchanger(endpoint string) *Exchanger {
return &Exchanger{
appID: "app-1",
clientSecret: "secret-1",
redirectURI: "https://example.test/app/",
endpoint: endpoint,
httpClient: &http.Client{},
}
}
func TestExchangeSuccessSendsConfidentialPKCE(t *testing.T) {
var gotForm url.Values
var gotContentType string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotContentType = r.Header.Get("Content-Type")
body, _ := io.ReadAll(r.Body)
gotForm, _ = url.ParseQuery(string(body))
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"user_id":"12345","access_token":"a","id_token":"h.e.s"}`)
}))
defer srv.Close()
id, err := testExchanger(srv.URL).Exchange(context.Background(), "the-code", "dev-9", "verifier-xyz")
if err != nil {
t.Fatalf("Exchange: %v", err)
}
if id.ExternalID != "12345" {
t.Fatalf("ExternalID = %q, want 12345", id.ExternalID)
}
if gotContentType != "application/x-www-form-urlencoded" {
t.Errorf("Content-Type = %q", gotContentType)
}
// The confidential exchange must carry the PKCE inputs and the app credentials.
want := map[string]string{
"grant_type": "authorization_code",
"code": "the-code",
"code_verifier": "verifier-xyz",
"device_id": "dev-9",
"client_id": "app-1",
"client_secret": "secret-1",
"redirect_uri": "https://example.test/app/",
}
for k, v := range want {
if gotForm.Get(k) != v {
t.Errorf("form[%s] = %q, want %q", k, gotForm.Get(k), v)
}
}
}
func TestExchangeAcceptsNumericUserID(t *testing.T) {
// VK returns user_id as a bare JSON number in some responses; it must parse too.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"user_id":1234567890,"access_token":"a"}`)
}))
defer srv.Close()
id, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
if err != nil {
t.Fatalf("Exchange: %v", err)
}
if id.ExternalID != "1234567890" {
t.Fatalf("ExternalID = %q, want 1234567890", id.ExternalID)
}
}
func TestExchangeFallsBackToIDTokenSub(t *testing.T) {
sub := "987654321"
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"` + sub + `"}`))
idToken := "header." + payload + ".sig"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"access_token":"a","id_token":"`+idToken+`"}`)
}))
defer srv.Close()
id, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
if err != nil {
t.Fatalf("Exchange: %v", err)
}
if id.ExternalID != sub {
t.Fatalf("ExternalID = %q, want %q (from id_token sub)", id.ExternalID, sub)
}
}
func TestExchangeRejectedIsErrInvalid(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"error":"invalid_grant","error_description":"code expired"}`)
}))
defer srv.Close()
_, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
if !errors.Is(err, ErrInvalid) {
t.Fatalf("err = %v, want ErrInvalid", err)
}
}
func TestExchangeEmptyInputsFailFast(t *testing.T) {
// Missing PKCE inputs are rejected without any network call.
ex := testExchanger("http://127.0.0.1:0/never")
for _, args := range [][3]string{{"", "d", "v"}, {"c", "", "v"}, {"c", "d", ""}} {
if _, err := ex.Exchange(context.Background(), args[0], args[1], args[2]); !errors.Is(err, ErrInvalid) {
t.Errorf("Exchange%v err = %v, want ErrInvalid", args, err)
}
}
}
func TestExchangeTransportErrorIsNotErrInvalid(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
srv.Close() // closed listener → connection refused
_, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
if err == nil {
t.Fatal("want a transport error")
}
if errors.Is(err, ErrInvalid) {
t.Fatalf("transport error must not be ErrInvalid: %v", err)
}
}
+11
View File
@@ -501,6 +501,17 @@ table LinkTelegramRequest {
data:string; data:string;
} }
// LinkVKRequest carries a VK ID web authorization for attaching a VK identity to the
// current account. The fields are the PKCE code-exchange inputs the frontend obtains
// from the VK ID SDK (One Tap): the authorization code, the device id issued with it,
// and the PKCE code verifier. The gateway completes the confidential code exchange
// server-side (under the app's protected key) to obtain the trusted vk user id.
table LinkVKRequest {
code:string;
device_id:string;
code_verifier:string;
}
// LinkUnlinkRequest detaches a platform identity (kind = "telegram" | "vk") from the // LinkUnlinkRequest detaches a platform identity (kind = "telegram" | "vk") from the
// caller's account; email is never unlinked (it is changed). // caller's account; email is never unlinked (it is changed).
table LinkUnlinkRequest { table LinkUnlinkRequest {
+82
View File
@@ -0,0 +1,82 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type LinkVKRequest struct {
_tab flatbuffers.Table
}
func GetRootAsLinkVKRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkVKRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &LinkVKRequest{}
x.Init(buf, n+offset)
return x
}
func FinishLinkVKRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsLinkVKRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkVKRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &LinkVKRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedLinkVKRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *LinkVKRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *LinkVKRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *LinkVKRequest) Code() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *LinkVKRequest) DeviceId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *LinkVKRequest) CodeVerifier() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func LinkVKRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(3)
}
func LinkVKRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0)
}
func LinkVKRequestAddDeviceId(builder *flatbuffers.Builder, deviceId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(deviceId), 0)
}
func LinkVKRequestAddCodeVerifier(builder *flatbuffers.Builder, codeVerifier flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(codeVerifier), 0)
}
func LinkVKRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+1
View File
@@ -54,6 +54,7 @@ export { LinkEmailRequest } from './scrabblefb/link-email-request.js';
export { LinkResult } from './scrabblefb/link-result.js'; export { LinkResult } from './scrabblefb/link-result.js';
export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js'; export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js';
export { LinkUnlinkRequest } from './scrabblefb/link-unlink-request.js'; export { LinkUnlinkRequest } from './scrabblefb/link-unlink-request.js';
export { LinkVKRequest } from './scrabblefb/link-vkrequest.js';
export { MatchFoundEvent } from './scrabblefb/match-found-event.js'; export { MatchFoundEvent } from './scrabblefb/match-found-event.js';
export { MatchResult } from './scrabblefb/match-result.js'; export { MatchResult } from './scrabblefb/match-result.js';
export { MoveRecord } from './scrabblefb/move-record.js'; export { MoveRecord } from './scrabblefb/move-record.js';
@@ -0,0 +1,72 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class LinkVKRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):LinkVKRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsLinkVKRequest(bb:flatbuffers.ByteBuffer, obj?:LinkVKRequest):LinkVKRequest {
return (obj || new LinkVKRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsLinkVKRequest(bb:flatbuffers.ByteBuffer, obj?:LinkVKRequest):LinkVKRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new LinkVKRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
code():string|null
code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
code(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
deviceId():string|null
deviceId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
deviceId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
codeVerifier():string|null
codeVerifier(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
codeVerifier(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startLinkVKRequest(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, codeOffset, 0);
}
static addDeviceId(builder:flatbuffers.Builder, deviceIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, deviceIdOffset, 0);
}
static addCodeVerifier(builder:flatbuffers.Builder, codeVerifierOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, codeVerifierOffset, 0);
}
static endLinkVKRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createLinkVKRequest(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset, deviceIdOffset:flatbuffers.Offset, codeVerifierOffset:flatbuffers.Offset):flatbuffers.Offset {
LinkVKRequest.startLinkVKRequest(builder);
LinkVKRequest.addCode(builder, codeOffset);
LinkVKRequest.addDeviceId(builder, deviceIdOffset);
LinkVKRequest.addCodeVerifier(builder, codeVerifierOffset);
return LinkVKRequest.endLinkVKRequest(builder);
}
}
+17 -1
View File
@@ -34,6 +34,7 @@ import {
telegramCloudSet, telegramCloudSet,
} from './telegram'; } from './telegram';
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk'; import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
import { pendingVKLink, type VKLinkCallback } from './vkid';
import { haptic } from './haptics'; import { haptic } from './haptics';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
@@ -96,6 +97,10 @@ export const app = $state<{
/** Set once the account has been deleted: App.svelte shows the terminal "account deleted" /** Set once the account has been deleted: App.svelte shows the terminal "account deleted"
* screen and all push/poll is stopped. Reopening the app just creates a fresh account. */ * screen and all push/poll is stopped. Reopening the app just creates a fresh account. */
accountDeleted: boolean; accountDeleted: boolean;
/** A VK ID web-link authorization callback captured on boot (see lib/vkid): the browser
* returned from VK with an auth code. Profile consumes it on mount to finish the link or
* merge (a full-page redirect loses the route, so boot routes here). Null on a normal load. */
vkLinkPending: VKLinkCallback | null;
toast: Toast | null; toast: Toast | null;
lastEvent: PushEvent | null; lastEvent: PushEvent | null;
theme: ThemePref; theme: ThemePref;
@@ -145,6 +150,7 @@ export const app = $state<{
profile: null, profile: null,
blocked: null, blocked: null,
accountDeleted: false, accountDeleted: false,
vkLinkPending: null,
toast: null, toast: null,
lastEvent: null, lastEvent: null,
theme: 'auto', theme: 'auto',
@@ -778,9 +784,19 @@ export async function bootstrap(): Promise<void> {
} }
const saved = await loadSession(); const saved = await loadSession();
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
// from VK; capture and clear it here (it needs the restored session to link against).
const vkcb = pendingVKLink();
if (saved) { if (saved) {
await adoptSession(saved); await adoptSession(saved);
if (router.route.name === 'login') navigate('/'); if (vkcb) {
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
// finishes the link or merge on mount.
app.vkLinkPending = vkcb;
navigate('/profile');
} else if (router.route.name === 'login') {
navigate('/');
}
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') { } else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
navigate('/login'); navigate('/login');
} }
+4
View File
@@ -177,6 +177,10 @@ export interface GatewayClient {
linkEmailMerge(email: string, code: string): Promise<LinkResult>; linkEmailMerge(email: string, code: string): Promise<LinkResult>;
linkTelegram(data: string): Promise<LinkResult>; linkTelegram(data: string): Promise<LinkResult>;
linkTelegramMerge(data: string): Promise<LinkResult>; linkTelegramMerge(data: string): Promise<LinkResult>;
/** Link a VK identity from the web: the args are the PKCE outputs of the VK ID
* authorization callback, exchanged for the trusted vk id on the gateway. */
linkVK(code: string, deviceId: string, codeVerifier: string): Promise<LinkResult>;
linkVKMerge(code: string, deviceId: string, codeVerifier: string): Promise<LinkResult>;
/** Detach a platform identity (kind 'telegram' | 'vk') from the account; email is never /** Detach a platform identity (kind 'telegram' | 'vk') from the account; email is never
* unlinked. Returns the refreshed link result (status 'unlinked'). */ * unlinked. Returns the refreshed link result (status 'unlinked'). */
linkUnlink(kind: string): Promise<LinkResult>; linkUnlink(kind: string): Promise<LinkResult>;
+8
View File
@@ -31,6 +31,7 @@ import {
decodeDeleteRequestResult, decodeDeleteRequestResult,
encodeGuestLogin, encodeGuestLogin,
encodeLinkUnlink, encodeLinkUnlink,
encodeLinkVK,
encodeStateRequest, encodeStateRequest,
encodeSubmitPlay, encodeSubmitPlay,
encodeTarget, encodeTarget,
@@ -466,6 +467,13 @@ describe('codec', () => {
expect(r.kind()).toBe('telegram'); expect(r.kind()).toBe('telegram');
}); });
it('encodes a VK link request carrying the PKCE code-exchange inputs', () => {
const r = fb.LinkVKRequest.getRootAsLinkVKRequest(new ByteBuffer(encodeLinkVK('the-code', 'dev-1', 'verifier-1')));
expect(r.code()).toBe('the-code');
expect(r.deviceId()).toBe('dev-1');
expect(r.codeVerifier()).toBe('verifier-1');
});
it('round-trips the account-delete confirm + request-result wire', () => { it('round-trips the account-delete confirm + request-result wire', () => {
const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm( const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm(
new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')), new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')),
+12
View File
@@ -739,6 +739,18 @@ export function encodeLinkUnlink(kind: string): Uint8Array {
return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b)); return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b));
} }
export function encodeLinkVK(code: string, deviceId: string, codeVerifier: string): Uint8Array {
const b = new Builder(256);
const c = b.createString(code);
const d = b.createString(deviceId);
const v = b.createString(codeVerifier);
fb.LinkVKRequest.startLinkVKRequest(b);
fb.LinkVKRequest.addCode(b, c);
fb.LinkVKRequest.addDeviceId(b, d);
fb.LinkVKRequest.addCodeVerifier(b, v);
return finish(b, fb.LinkVKRequest.endLinkVKRequest(b));
}
export function encodeAccountDeleteConfirm(code: string, phrase: string): Uint8Array { export function encodeAccountDeleteConfirm(code: string, phrase: string): Uint8Array {
const b = new Builder(64); const b = new Builder(64);
const c = b.createString(code); const c = b.createString(code);
+1
View File
@@ -173,6 +173,7 @@ export const en = {
'profile.guestLocked': 'Sign in with email to manage your profile.', 'profile.guestLocked': 'Sign in with email to manage your profile.',
'profile.linkAccount': 'Link an account', 'profile.linkAccount': 'Link an account',
'profile.linkTelegram': 'Link Telegram', 'profile.linkTelegram': 'Link Telegram',
'profile.linkVK': 'Link VK',
'profile.linked': 'Account linked.', 'profile.linked': 'Account linked.',
'profile.merged': 'Accounts merged.', 'profile.merged': 'Accounts merged.',
'profile.mergeTitle': 'Merge accounts?', 'profile.mergeTitle': 'Merge accounts?',
+1
View File
@@ -173,6 +173,7 @@ export const ru: Record<MessageKey, string> = {
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.', 'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
'profile.linkAccount': 'Привязать аккаунт', 'profile.linkAccount': 'Привязать аккаунт',
'profile.linkTelegram': 'Привязать Telegram', 'profile.linkTelegram': 'Привязать Telegram',
'profile.linkVK': 'Привязать VK',
'profile.linked': 'Аккаунт привязан.', 'profile.linked': 'Аккаунт привязан.',
'profile.merged': 'Аккаунты объединены.', 'profile.merged': 'Аккаунты объединены.',
'profile.mergeTitle': 'Объединить аккаунты?', 'profile.mergeTitle': 'Объединить аккаунты?',
+10
View File
@@ -674,6 +674,16 @@ export class MockGateway implements GatewayClient {
this.profile.telegramLinked = true; this.profile.telegramLinked = true;
return { ...emptyLinked(), status: 'merged' }; return { ...emptyLinked(), status: 'merged' };
} }
async linkVK(_code: string, _deviceId: string, _codeVerifier: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.vkLinked = true;
return emptyLinked();
}
async linkVKMerge(_code: string, _deviceId: string, _codeVerifier: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.vkLinked = true;
return { ...emptyLinked(), status: 'merged' };
}
async linkUnlink(kind: string): Promise<LinkResult> { async linkUnlink(kind: string): Promise<LinkResult> {
if (kind === 'telegram') this.profile.telegramLinked = false; if (kind === 'telegram') this.profile.telegramLinked = false;
if (kind === 'vk') this.profile.vkLinked = false; if (kind === 'vk') this.profile.vkLinked = false;
+6
View File
@@ -268,6 +268,12 @@ export function createTransport(baseUrl: string): GatewayClient {
async linkTelegramMerge(data) { async linkTelegramMerge(data) {
return codec.decodeLinkResult(await exec('link.telegram.merge', codec.encodeLinkTelegram(data))); return codec.decodeLinkResult(await exec('link.telegram.merge', codec.encodeLinkTelegram(data)));
}, },
async linkVK(code, deviceId, codeVerifier) {
return codec.decodeLinkResult(await exec('link.vk.confirm', codec.encodeLinkVK(code, deviceId, codeVerifier)));
},
async linkVKMerge(code, deviceId, codeVerifier) {
return codec.decodeLinkResult(await exec('link.vk.merge', codec.encodeLinkVK(code, deviceId, codeVerifier)));
},
async linkUnlink(kind) { async linkUnlink(kind) {
return codec.decodeLinkResult(await exec('link.unlink', codec.encodeLinkUnlink(kind))); return codec.decodeLinkResult(await exec('link.unlink', codec.encodeLinkUnlink(kind)));
}, },
+122
View File
@@ -0,0 +1,122 @@
// VK ID web login for linking a VK identity to the current account from a browser.
// Unlike the VK Mini App (whose signed launch params authenticate offline), a browser
// has no launch signature, so it runs the VK ID raw OAuth 2.1 flow (PKCE, no @vkid/sdk):
// a full-page redirect to VK's hosted login, then a callback to our redirect URL carrying
// the authorization code. The gateway completes the confidential code exchange. This is
// web-only — a full-page redirect would strand a native Mini App webview, where the
// platform identity is already linked anyway.
import { insideTelegram } from './telegram';
import { insideVK } from './vk';
function isMock(): boolean {
return import.meta.env.MODE === 'mock';
}
function vkAppId(): string {
return ((import.meta.env.VITE_VK_APP_ID as string | undefined) ?? '').trim();
}
function vkRedirectUrl(): string {
return ((import.meta.env.VITE_VK_ID_REDIRECT_URL as string | undefined) ?? '').trim();
}
/**
* vkWebLinkAvailable reports whether the "Link VK" control should be shown: on the plain
* web (not inside a Telegram/VK Mini App, where a redirect would break the webview) and
* either the mock build or a configured VK ID app id and redirect URL.
*/
export function vkWebLinkAvailable(): boolean {
if (insideTelegram() || insideVK()) return false;
if (isMock()) return true;
return vkAppId() !== '' && vkRedirectUrl() !== '';
}
const STORAGE_KEY = 'vkid.link';
// PendingState is stashed before the redirect and consumed on the callback: the PKCE
// verifier (needed for the gateway exchange), the CSRF state, and whether this is an
// initial link or the re-authorization for a merge (VK codes are single-use, so a merge
// cannot reuse the link's code — it re-authorizes for a fresh one).
type PendingState = { verifier: string; state: string; mode: 'link' | 'merge' };
/** VKLinkCallback carries a verified VK ID authorization callback for the gateway exchange. */
export type VKLinkCallback = { code: string; deviceId: string; verifier: string; mode: 'link' | 'merge' };
// b64url encodes bytes as base64url without padding (the PKCE / VK ID alphabet).
function b64url(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function randomToken(bytes: number): string {
const a = new Uint8Array(bytes);
crypto.getRandomValues(a);
return b64url(a);
}
async function challengeFrom(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return b64url(new Uint8Array(digest));
}
/**
* startVKLink begins VK ID web authorization by redirecting the whole tab to VK's hosted
* login. The PKCE verifier and CSRF state survive the redirect in sessionStorage; VK
* returns to VITE_VK_ID_REDIRECT_URL with ?code&device_id&state, processed on the next
* boot via pendingVKLink. mode distinguishes an initial link from a merge re-auth. The
* mock build never leaves the SPA.
*/
export async function startVKLink(mode: 'link' | 'merge'): Promise<void> {
if (isMock()) return;
const verifier = randomToken(48); // 64 base64url chars (VK ID requires 43..128)
const state = randomToken(24); // 32 base64url chars (VK ID requires >= 32)
const challenge = await challengeFrom(verifier);
const pending: PendingState = { verifier, state, mode };
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(pending));
const url = new URL('https://id.vk.com/authorize');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', vkAppId());
url.searchParams.set('redirect_uri', vkRedirectUrl());
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('scope', 'vkid.personal_info');
location.assign(url.toString());
}
// stripCallbackQuery removes the OAuth query params from the URL so a refresh does not
// re-process the callback, keeping the path and any hash route intact.
function stripCallbackQuery(): void {
if (typeof history !== 'undefined' && history.replaceState) {
history.replaceState(null, '', location.pathname + location.hash);
}
}
/**
* pendingVKLink extracts and clears a VK ID authorization callback from the current URL,
* verifying the returned state against the one stored before the redirect (CSRF). It
* returns null on a normal load or when the state does not match, and always strips the
* query so a refresh does not re-run it.
*/
export function pendingVKLink(): VKLinkCallback | null {
if (typeof location === 'undefined') return null;
const q = new URLSearchParams(location.search);
const code = q.get('code');
const deviceId = q.get('device_id');
const returnedState = q.get('state');
if (!code || !deviceId || !returnedState) return null;
const raw = sessionStorage.getItem(STORAGE_KEY);
sessionStorage.removeItem(STORAGE_KEY);
stripCallbackQuery();
if (!raw) return null;
let pending: PendingState;
try {
pending = JSON.parse(raw) as PendingState;
} catch {
return null;
}
if (pending.state !== returnedState) return null;
return { code, deviceId, verifier: pending.verifier, mode: pending.mode };
}
+54 -3
View File
@@ -14,6 +14,7 @@
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram'; import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
import { startVKLink, vkWebLinkAvailable } from '../lib/vkid';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { import {
awayDurationOk, awayDurationOk,
@@ -45,7 +46,7 @@
let emailSent = $state(false); let emailSent = $state(false);
// A pending irreversible merge surfaced after the code/widget was verified; the // A pending irreversible merge surfaced after the code/widget was verified; the
// dialog confirms it. tgData holds the Telegram widget payload for the merge step. // dialog confirms it. tgData holds the Telegram widget payload for the merge step.
let pendingMerge = $state<null | { kind: 'email' | 'telegram'; name: string; games: number; friends: number }>(null); let pendingMerge = $state<null | { kind: 'email' | 'telegram' | 'vk'; name: string; games: number; friends: number }>(null);
let tgData = ''; let tgData = '';
// The change-email sub-form is open (a new address is being entered/confirmed). // The change-email sub-form is open (a new address is being entered/confirmed).
let changingEmail = $state(false); let changingEmail = $state(false);
@@ -56,6 +57,7 @@
let deleteCode = $state(''); let deleteCode = $state('');
let deletePhrase = $state(''); let deletePhrase = $state('');
const telegramLinkable = loginWidgetAvailable(); const telegramLinkable = loginWidgetAvailable();
const vkLinkable = vkWebLinkAvailable();
function defaultTz(): string { function defaultTz(): string {
const b = browserOffset(); const b = browserOffset();
@@ -82,7 +84,12 @@
notificationsInAppOnly = p.notificationsInAppOnly; notificationsInAppOnly = p.notificationsInAppOnly;
variantPrefs = [...p.variantPreferences]; variantPrefs = [...p.variantPreferences];
} }
onMount(populate); onMount(() => {
populate();
// A VK ID web-link callback captured on boot (app.vkLinkPending) is finished here, since
// the redirect back from VK lost the in-app route and lands the app on a fresh mount.
if (app.vkLinkPending) void processVKLink();
});
const awayStart = $derived(`${startH}:${startM}`); const awayStart = $derived(`${startH}:${startM}`);
const awayEnd = $derived(`${endH}:${endM}`); const awayEnd = $derived(`${endH}:${endM}`);
@@ -180,8 +187,48 @@
} }
} }
// addVK starts VK ID web login for linking: it redirects the whole tab to VK's hosted login,
// returning to the app with an auth code that boot hands back to processVKLink.
function addVK() {
void startVKLink('link');
}
// processVKLink finishes a VK ID web-link callback captured on boot: it exchanges the code via
// the gateway and either completes the link or opens the merge dialog. VK's authorization code
// is single-use, so a required merge re-authorizes for a fresh code (see confirmMerge); that
// returning 'merge' callback lands back here and completes.
async function processVKLink() {
const cb = app.vkLinkPending;
app.vkLinkPending = null;
if (!cb) return;
try {
if (cb.mode === 'merge') {
await applyLinkResult(await gateway.linkVKMerge(cb.code, cb.deviceId, cb.verifier));
populate();
showToast(t('profile.merged'));
return;
}
const r = await gateway.linkVK(cb.code, cb.deviceId, cb.verifier);
if (r.status === 'merge_required') {
pendingMerge = { kind: 'vk', name: r.secondaryDisplayName, games: r.secondaryGames, friends: r.secondaryFriends };
return;
}
await applyLinkResult(r);
populate();
showToast(t('profile.linked'));
} catch (e) {
handleError(e);
}
}
async function confirmMerge() { async function confirmMerge() {
if (!pendingMerge) return; if (!pendingMerge) return;
// A VK merge cannot reuse VK's single-use authorization code, so it re-authorizes for a fresh
// one; the returning callback completes it (processVKLink). Email/Telegram re-send their proof.
if (pendingMerge.kind === 'vk') {
void startVKLink('merge');
return;
}
try { try {
const r = const r =
pendingMerge.kind === 'email' pendingMerge.kind === 'email'
@@ -344,7 +391,7 @@
<!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email <!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email
(add) triggers the merge dialog below. On the web an account can add Telegram via the (add) triggers the merge dialog below. On the web an account can add Telegram via the
login widget; adding VK on the web is deferred (no VK OAuth) and inside a Mini App the login widget or VK via VK ID web login (a full-page redirect); inside a Mini App the
host provider is already linked. Email is never unlinked — it is changed. Unlink is host provider is already linked. Email is never unlinked — it is changed. Unlink is
offered only when another identity remains (the backend also refuses the last one). --> offered only when another identity remains (the backend also refuses the last one). -->
<section class="accounts"> <section class="accounts">
@@ -413,6 +460,10 @@
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button> <button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
{/if} {/if}
</div> </div>
{:else if vkLinkable}
<button class="ghost tg" onclick={addVK} disabled={!connection.online}>
<img src="vk-logo.svg" alt="" width="18" height="18" />{t('profile.linkVK')}
</button>
{/if} {/if}
</section> </section>