diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5e2091a..dcaa741 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -330,6 +330,10 @@ jobs: # 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. 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). EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }} # 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_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }} 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 }} # Unset vars render empty -> the compose ":-" defaults apply. POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index 9ee55ee..0186e4d 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -41,6 +41,9 @@ jobs: VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }} 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 }} POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }} GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} @@ -84,6 +87,11 @@ jobs: GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }} 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). EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }} # Transactional email via the shared Selectel relay (confirm-codes). @@ -167,6 +175,11 @@ jobs: export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN' export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL' 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 SMTP_RELAY_HOST='$SMTP_RELAY_HOST' export SMTP_RELAY_PORT='$SMTP_RELAY_PORT' diff --git a/backend/internal/inttest/merge_test.go b/backend/internal/inttest/merge_test.go index ad36c77..83cda98 100644 --- a/backend/internal/inttest/merge_test.go +++ b/backend/internal/inttest/merge_test.go @@ -355,3 +355,64 @@ func TestAccountLinkGuestInversion(t *testing.T) { 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) + } +} diff --git a/backend/internal/link/service.go b/backend/internal/link/service.go index bf0311f..8f502d5 100644 --- a/backend/internal/link/service.go +++ b/backend/internal/link/service.go @@ -133,6 +133,53 @@ func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, extern 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 // durable), runs the data merge, retires the secondary's sessions and mints a new // session when the active account switches. diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 6d55e1e..0909f67 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -74,6 +74,8 @@ func (s *Server) registerRoutes() { u.POST("/link/email/merge", s.handleLinkEmailMerge) u.POST("/link/telegram", s.handleLinkTelegram) 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) // 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 diff --git a/backend/internal/server/handlers_link.go b/backend/internal/server/handlers_link.go index efbe667..d8d01dd 100644 --- a/backend/internal/server/handlers_link.go +++ b/backend/internal/server/handlers_link.go @@ -34,6 +34,12 @@ type linkTelegramBody struct { 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 // "linked" (bound to the caller), "merge_required" (the identity belongs to another // 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)) } +// 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) // or a completed link (the active account's refreshed profile). func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse { diff --git a/deploy/.env.example b/deploy/.env.example index c8b1b13..e0f1a02 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -68,6 +68,8 @@ VITE_TELEGRAM_BOT_ID= VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me//) 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) +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= # --- Grafana ---------------------------------------------------------------- @@ -97,3 +99,7 @@ TELEGRAM_API_BASE_URL= # 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. 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= diff --git a/deploy/README.md b/deploy/README.md index 408f2f2..ba0872d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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//` — `` 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_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app`). | +| `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). | | `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). | @@ -212,11 +215,11 @@ players arrive. `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, 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, 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, -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_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL, GRAFANA_SMTP_PORT, GF_SMTP_ENABLED}`. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 6bfe29c..3b0c440 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -190,6 +190,8 @@ services: VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-} 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_APP_VERSION: ${APP_VERSION:-dev} # 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 # the VK auth path (auth.vk is then unregistered). 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 # 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 @@ -264,6 +274,8 @@ services: VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-} 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_APP_VERSION: ${APP_VERSION:-dev} restart: unless-stopped diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5f660eb..2fb8a7b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 flow, **Telegram** through the web **Login Widget** (validated by the validator, 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" signal, so a probe cannot enumerate registered addresses); a required **merge** 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`). - **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The backend **refuses removing the last identity** (`ErrLastIdentity`), so an account diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 08feae1..7bef505 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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. 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 -platform is already linked. A linked provider can be **unlinked** — except the last +Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and +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 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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index e06dd6e..4e32af7 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -112,8 +112,10 @@ Telegram держит **единого бота**: все игроки поль запрещено, только пока у аккаунтов есть общая незавершённая игра. В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить -Telegram; добавление VK в вебе пока не предлагается, а внутри Mini App платформа-хозяин -уже привязана. Привязанного провайдера можно **отвязать** — кроме последнего +Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и +обратно); внутри Mini App платформа-хозяин уже привязана. Привязка провайдера, уже +принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка +email. Привязанного провайдера можно **отвязать** — кроме последнего оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым. **Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код, отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 3cb62cc..aaf0a12 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -25,12 +25,16 @@ ARG VITE_TELEGRAM_BOT_ID= ARG VITE_TELEGRAM_LINK= ARG VITE_TELEGRAM_GAME_CHANNEL_NAME= ARG VITE_VK_APP_LINK= +ARG VITE_VK_APP_ID= +ARG VITE_VK_ID_REDIRECT_URL= ARG VITE_GATEWAY_URL= ARG VITE_APP_VERSION= ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \ 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_APP_VERSION=$VITE_APP_VERSION diff --git a/gateway/README.md b/gateway/README.md index 4327888..426c2fc 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -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 (`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`, `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; @@ -72,9 +78,10 @@ refetch). The social/account/history ops — `blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`, `stats.get`, `game.gcg`, and the `notify` live event — go through the identical transcode pattern (`transcode_social.go`). Account linking & merge -— `link.email.request/confirm/merge` and `link.telegram.confirm/merge` -(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the -validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These +— `link.email.request/confirm/merge`, `link.telegram.confirm/merge` and +`link.vk.confirm/merge` (`transcode_link.go`); the telegram ops validate the **Login +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. ## 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_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_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_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) | diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 52428f1..0bb975c 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -33,6 +33,7 @@ import ( "scrabble/gateway/internal/ratelimit" "scrabble/gateway/internal/session" "scrabble/gateway/internal/transcode" + "scrabble/gateway/internal/vkid" "scrabble/pkg/mtls" botlinkv1 "scrabble/pkg/proto/botlink/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)") } - 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{ Registry: registry, Sessions: sessions, diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index 7a79288..1b9596a 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -335,6 +335,24 @@ func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID strin 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 // authenticated email change. func (c *Client) ChangeEmailRequest(ctx context.Context, userID, email string) error { diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 98ea120..31fff16 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -36,6 +36,11 @@ type Config struct { // 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). 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 // empty BotLink.Addr disables the bot channel (out-of-app push and admin relay). 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 // validates the result. func Load() (Config, error) { @@ -174,6 +195,11 @@ func Load() (Config, error) { AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"), ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"), 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, RateLimit: DefaultRateLimit(), Abuse: DefaultAbuse(), diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 9a99a97..c876964 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -14,6 +14,7 @@ import ( "scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/connector" "scrabble/gateway/internal/vkauth" + "scrabble/gateway/internal/vkid" 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. func (r *Registry) Lookup(messageType string) (Op, bool) { op, ok := r.ops[messageType] @@ -172,6 +182,9 @@ func DomainCode(err error) (string, bool) { if errors.Is(err, vkauth.ErrInvalid) { return "invalid_vk_params", true } + if errors.Is(err, vkid.ErrInvalid) { + return "invalid_vk_id", true + } if errors.Is(err, connector.ErrInvalidLoginWidget) { return "invalid_login_widget", true } diff --git a/gateway/internal/transcode/transcode_link.go b/gateway/internal/transcode/transcode_link.go index 72f2c70..1401080 100644 --- a/gateway/internal/transcode/transcode_link.go +++ b/gateway/internal/transcode/transcode_link.go @@ -4,6 +4,7 @@ import ( "context" "scrabble/gateway/internal/backendclient" + "scrabble/gateway/internal/vkid" fb "scrabble/pkg/fbs/scrabblefb" ) @@ -18,6 +19,8 @@ const ( MsgLinkEmailMerge = "link.email.merge" MsgLinkTelegram = "link.telegram.confirm" MsgLinkTelegramMerge = "link.telegram.merge" + MsgLinkVK = "link.vk.confirm" + MsgLinkVKMerge = "link.vk.merge" MsgLinkUnlink = "link.unlink" MsgEmailChangeRequest = "link.email.change.request" MsgEmailChangeConfirm = "link.email.change.confirm" @@ -154,3 +157,44 @@ func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, me 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 + } +} diff --git a/gateway/internal/transcode/transcode_vk_link_test.go b/gateway/internal/transcode/transcode_vk_link_test.go new file mode 100644 index 0000000..e955237 --- /dev/null +++ b/gateway/internal/transcode/transcode_vk_link_test.go @@ -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") + } +} diff --git a/gateway/internal/vkid/vkid.go b/gateway/internal/vkid/vkid.go new file mode 100644 index 0000000..532ac76 --- /dev/null +++ b/gateway/internal/vkid/vkid.go @@ -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 "" +} diff --git a/gateway/internal/vkid/vkid_test.go b/gateway/internal/vkid/vkid_test.go new file mode 100644 index 0000000..08dc29a --- /dev/null +++ b/gateway/internal/vkid/vkid_test.go @@ -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) + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index c078538..570b98c 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -501,6 +501,17 @@ table LinkTelegramRequest { 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 // caller's account; email is never unlinked (it is changed). table LinkUnlinkRequest { diff --git a/pkg/fbs/scrabblefb/LinkVKRequest.go b/pkg/fbs/scrabblefb/LinkVKRequest.go new file mode 100644 index 0000000..1514fa1 --- /dev/null +++ b/pkg/fbs/scrabblefb/LinkVKRequest.go @@ -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() +} diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 73aa84a..bc2d768 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -54,6 +54,7 @@ export { LinkEmailRequest } from './scrabblefb/link-email-request.js'; export { LinkResult } from './scrabblefb/link-result.js'; export { LinkTelegramRequest } from './scrabblefb/link-telegram-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 { MatchResult } from './scrabblefb/match-result.js'; export { MoveRecord } from './scrabblefb/move-record.js'; diff --git a/ui/src/gen/fbs/scrabblefb/link-vkrequest.ts b/ui/src/gen/fbs/scrabblefb/link-vkrequest.ts new file mode 100644 index 0000000..8f4076c --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/link-vkrequest.ts @@ -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); +} +} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 38593c7..07be39e 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -34,6 +34,7 @@ import { telegramCloudSet, } from './telegram'; 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 { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; 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" * screen and all push/poll is stopped. Reopening the app just creates a fresh account. */ 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; lastEvent: PushEvent | null; theme: ThemePref; @@ -145,6 +150,7 @@ export const app = $state<{ profile: null, blocked: null, accountDeleted: false, + vkLinkPending: null, toast: null, lastEvent: null, theme: 'auto', @@ -778,9 +784,19 @@ export async function bootstrap(): Promise { } 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) { 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') { navigate('/login'); } diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index f6a235c..5c52348 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -177,6 +177,10 @@ export interface GatewayClient { linkEmailMerge(email: string, code: string): Promise; linkTelegram(data: string): Promise; linkTelegramMerge(data: string): Promise; + /** 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; + linkVKMerge(code: string, deviceId: string, codeVerifier: string): Promise; /** Detach a platform identity (kind 'telegram' | 'vk') from the account; email is never * unlinked. Returns the refreshed link result (status 'unlinked'). */ linkUnlink(kind: string): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 82d13c6..9f6d397 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -31,6 +31,7 @@ import { decodeDeleteRequestResult, encodeGuestLogin, encodeLinkUnlink, + encodeLinkVK, encodeStateRequest, encodeSubmitPlay, encodeTarget, @@ -466,6 +467,13 @@ describe('codec', () => { 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', () => { const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm( new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')), diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 4c34194..ebd60f8 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -739,6 +739,18 @@ export function encodeLinkUnlink(kind: string): Uint8Array { 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 { const b = new Builder(64); const c = b.createString(code); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 5aef200..1739be6 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -173,6 +173,7 @@ export const en = { 'profile.guestLocked': 'Sign in with email to manage your profile.', 'profile.linkAccount': 'Link an account', 'profile.linkTelegram': 'Link Telegram', + 'profile.linkVK': 'Link VK', 'profile.linked': 'Account linked.', 'profile.merged': 'Accounts merged.', 'profile.mergeTitle': 'Merge accounts?', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 9023d99..4ee0347 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -173,6 +173,7 @@ export const ru: Record = { 'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.', 'profile.linkAccount': 'Привязать аккаунт', 'profile.linkTelegram': 'Привязать Telegram', + 'profile.linkVK': 'Привязать VK', 'profile.linked': 'Аккаунт привязан.', 'profile.merged': 'Аккаунты объединены.', 'profile.mergeTitle': 'Объединить аккаунты?', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index f0d27cc..b84bb95 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -674,6 +674,16 @@ export class MockGateway implements GatewayClient { this.profile.telegramLinked = true; return { ...emptyLinked(), status: 'merged' }; } + async linkVK(_code: string, _deviceId: string, _codeVerifier: string): Promise { + this.profile.isGuest = false; + this.profile.vkLinked = true; + return emptyLinked(); + } + async linkVKMerge(_code: string, _deviceId: string, _codeVerifier: string): Promise { + this.profile.isGuest = false; + this.profile.vkLinked = true; + return { ...emptyLinked(), status: 'merged' }; + } async linkUnlink(kind: string): Promise { if (kind === 'telegram') this.profile.telegramLinked = false; if (kind === 'vk') this.profile.vkLinked = false; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 12863af..1ec3551 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -268,6 +268,12 @@ export function createTransport(baseUrl: string): GatewayClient { async linkTelegramMerge(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) { return codec.decodeLinkResult(await exec('link.unlink', codec.encodeLinkUnlink(kind))); }, diff --git a/ui/src/lib/vkid.ts b/ui/src/lib/vkid.ts new file mode 100644 index 0000000..f53da57 --- /dev/null +++ b/ui/src/lib/vkid.ts @@ -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 { + 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 { + 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 }; +} diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index 9cc9a3f..b5f1d28 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -14,6 +14,7 @@ import { connection } from '../lib/connection.svelte'; import { gateway } from '../lib/gateway'; import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram'; + import { startVKLink, vkWebLinkAvailable } from '../lib/vkid'; import { t } from '../lib/i18n/index.svelte'; import { awayDurationOk, @@ -45,7 +46,7 @@ let emailSent = $state(false); // 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. - let pendingMerge = $state(null); + let pendingMerge = $state(null); let tgData = ''; // The change-email sub-form is open (a new address is being entered/confirmed). let changingEmail = $state(false); @@ -56,6 +57,7 @@ let deleteCode = $state(''); let deletePhrase = $state(''); const telegramLinkable = loginWidgetAvailable(); + const vkLinkable = vkWebLinkAvailable(); function defaultTz(): string { const b = browserOffset(); @@ -82,7 +84,12 @@ notificationsInAppOnly = p.notificationsInAppOnly; 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 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() { 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 { const r = pendingMerge.kind === 'email' @@ -344,7 +391,7 @@
@@ -413,6 +460,10 @@ {/if} + {:else if vkLinkable} + {/if}